Set Package Boundaries in TypeScript
Learn how to set clear package boundaries in TypeScript projects using folder conventions, ESLint rules, and dependency direction to keep a large codebase maintainable.
A package boundary is a rule that says "this folder can import from that folder, but not the other way around." In a small project, every file can import from every other file and things still work. In a large project, unrestricted imports create a tangled dependency graph where changing one utility function requires retesting half the application.
TypeScript does not enforce package boundaries on its own. The compiler checks types, not architecture. You need folder conventions and lint rules to define boundaries.
Once boundaries are in place, they prevent the most common cause of large-codebase decay: accidental coupling between unrelated modules.
Define Your Layers
Every codebase has natural layers. A typical TypeScript application has at least four, each with a specific responsibility and a strict dependency direction:
The arrows show the only allowed import direction. Shared code is imported by everyone. Domain imports from shared.
Features import from domain and shared. Infrastructure imports from shared only, and is connected to features through dependency injection rather than direct imports.
A concrete folder structure reflects these layers directly:
src/
shared/ -- types, utilities, constants (no business logic)
domain/ -- models, service interfaces, business rules
features/ -- use cases, controllers, route handlers
infrastructure/ -- database clients, HTTP servers, config loadersThe critical rule is simple: no folder imports from a folder above it. Domain cannot import from features, and shared cannot import from anyone.
This rule alone prevents the most damaging kind of coupling in a growing codebase.
Enforce Boundaries with ESLint
Folder structure is a suggestion. ESLint rules make it a requirement that runs in CI and blocks pull requests. The import/no-restricted-paths rule defines zones that certain directories cannot import from:
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/domain",
"from": "./src/features",
"message": "Domain cannot import from features. Use dependency inversion."
}
]
}
]
}
}When someone tries to import a feature module into domain code, the linter blocks the pull request with a clear error message that explains why it is blocked and what to do instead. This rule becomes part of your CI pipeline and runs on every commit.
You can tighten boundaries further for specific feature folders. Preventing sibling feature folders from importing each other directly forces shared logic through the proper channels. Here is how you would add a zone to block imports between checkout and dashboard:
{
"target": "./src/features/checkout",
"from": "./src/features/dashboard",
"message": "Features must not import from sibling features. Use shared services or events."
}Each zone rule is self-documenting. The message tells the developer exactly what pattern is forbidden and what the correct alternative is.
Design a Public API for Each Package
A package should expose a deliberate set of exports. Internal implementation details stay hidden behind an index.ts barrel file that acts as the package's public API:
checkout/
index.ts -- public API: exports only what consumers need
checkout.service.ts
checkout.types.ts
pricing-calculator.ts -- internal, not exported from index.ts
__tests__/The barrel file exports exactly the public surface and nothing more, keeping internal implementation details hidden from all external consumers of the package:
// checkout/index.ts
export { createCheckout } from "./checkout.service";
export type { CheckoutRequest, CheckoutResult } from "./checkout.types";Consumers outside the checkout package import only from the public API. They never reach into internal files directly, which means you can refactor the internals freely:
import { createCheckout } from "@/features/checkout";This means you can refactor the internal pricing calculator freely. As long as the exported function keeps its signature, no consumer breaks. The public API is the contract between the package and the rest of the codebase.
Handle Cross-Cutting Concerns
Some types and utilities are genuinely needed everywhere. A Result type or a formatting function belongs in shared. But be strict about what qualifies: if only two features use a type, it does not belong in shared.
When a concern crosses layer boundaries, use dependency inversion. A domain service that needs to send email should not import an email client directly. Instead, define an interface in the domain layer and implement it in infrastructure:
// domain/notifications.ts
type EmailService = {
send: (to: string, subject: string, body: string) => Promise<void>;
};The domain service depends on this interface, not on any specific email provider. The infrastructure layer provides the actual implementation:
// infrastructure/mailgun-email-service.ts
const mailgunEmailService: EmailService = {
send: async (to, subject, body) => {
await fetch("https://api.mailgun.net/v3/...", { /* config */ });
},
};The domain defines what it needs. Infrastructure provides it, and neither layer knows about the other's implementation details.
For more on this pattern, see create service layers with TypeScript.
Name Packages by Responsibility
Package names signal what belongs inside. A package named after a technology tells you it contains integration code. A package named after a responsibility tells you it contains business logic that today happens to use that technology.
| Avoid (named by technology) | Prefer (named by responsibility) |
|---|---|
| stripe/ | billing/ |
| sendgrid/ | notifications/ |
| postgres/ | persistence/ |
| redis/ | cache/ |
The technology-specific code lives inside the responsibility-named package. If billing moves from Stripe to Paddle, you replace one implementation file. The public API stays the same.
When to Split a Package
A package that does too many things becomes a dumping ground. Signs that a package should be split include: the barrel file exports more than 15 symbols, the tests set up unrelated mocks, changing one feature forces changes to seemingly unrelated files, or the package name contains "and" or "common."
When splitting, move files to the new package and update the old barrel to re-export from the new location temporarily. This keeps consumers compiling during the migration. Remove the re-exports once all consumers have moved.
Common Mistakes
Using export star in barrel files. This leaks every internal type and makes the public API impossible to audit. Always use named exports so the public surface is explicit and intentional.
Putting all shared code in a single utils folder. Utils is where code goes to be forgotten. If a function is shared, give it a meaningful home like formatting, validation, or datetime.
Allowing feature-to-feature imports. When one feature imports from another feature directly, both become harder to change independently. Route shared logic through the shared layer or lift it into a domain service.
Enforcing boundaries only through code review. Humans miss things. ESLint rules in CI catch every violation, every time. The rules are the architecture documentation.
Creating a package for everything. Boundaries have a cost: more files, more barrel files, more indirection. Start with the four-layer structure and add boundaries only when a specific coupling problem appears.
For more on keeping imports clean, see avoid circular imports in TypeScript.
Rune AI
Key Insights
- Package boundaries define which modules can import from which, enforced by folder structure and lint rules.
- Dependency direction flows one way: shared code is imported by features, never the reverse.
- Each package exposes a public API through an index.ts barrel; internal files are never imported directly from outside.
- ESLint rules like import/no-restricted-paths enforce boundaries in CI.
- A package should be named after its responsibility, not its technology: billing, not stripe.
Frequently Asked Questions
What is a package boundary in TypeScript?
How do I enforce package boundaries beyond folder structure?
Conclusion
Package boundaries are the walls that keep a large TypeScript codebase from collapsing into a ball of imports. By defining clear layers, enforcing dependency direction with lint rules, and exposing only intentional public APIs from each package, you create a codebase where changes in one area do not silently break code in another.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.