TypeScript architecture is often reduced to folders, naming conventions, and a single tsconfig. The harder decision is how compile-time intent meets runtime reality. A type can describe a value without checking data from a request, queue, file, or third-party package. A path alias can compile while a Node.js process resolves a different module. A shared package can make imports convenient while exposing implementation details that are difficult to change. This guide treats TypeScript architecture as a boundary problem: define what each module owns, make runtime validation explicit, publish only stable surfaces, and use the build graph as evidence that the design is coherent.
Name the architecture boundary
Start with the business capabilities and runtime processes the system must support. A web handler, worker, command-line tool, and shared domain module may compile in one repository but have different failure modes and deployment lifecycles. Draw which layer can call which, which data crosses the boundary, and which module owns a rule. Keep infrastructure adapters at the edge and expose an interface that the use case can test. A boundary is useful when it prevents a change in a database driver, queue client, or framework from leaking into every caller. If the repository is small, use fewer layers but keep the ownership statement just as clear.

| Boundary | Question to settle | Healthy evidence |
|---|---|---|
| Runtime | Which process loads and executes this module? | Entry point, package mode, and startup test |
| Data | Where is external input validated and normalized? | Schema check and trusted internal type |
| Dependency | Which layer may call a database, queue, or HTTP client? | Adapter interface and test double |
| Public API | What can another package import safely? | Explicit export map and declaration output |
| Build | What must compile before this package? | Project reference graph and clean build |
Separate type intent from runtime truth
Treat data from outside the process as unknown until it has passed a runtime check. That includes JSON parsed from an API, environment variables, persisted records, webhook payloads, and values returned by a JavaScript dependency. After validation, convert it into a narrower domain type and make the conversion visible. Do not scatter unchecked casts across handlers to silence the compiler. A small boundary parser can report which field failed, preserve safe context for support, and keep the rest of the application free to rely on stronger invariants. This is where a type system and a product rule meet: the code should express what the business considers valid, not merely what arrived over the wire.
Make module resolution a design decision
Choose a module model that matches the runtime and publish it consistently. The TypeScript Modules Reference explains that the compiler's resolution mode and the runtime's interpretation must agree. Node.js package documentation shows how package.json fields, type, exports, and imports affect what consumers can load. Test the built artifact in the same mode that production uses; a successful editor import is not proof that a packaged worker can resolve it. Keep aliases, extension rules, conditional exports, and generated files in one documented decision record.
Publish a small, intentional package surface
An internal package becomes a dependency contract as soon as another service or application imports it. Export domain types, functions, and factories that express stable intent; keep adapters, generated clients, and test helpers private unless consumers truly need them. Use an explicit exports map where the runtime supports it so deep imports cannot become accidental public API. Generate declaration files for a package that other TypeScript projects consume and review them as part of the release. The TypeScript declaration files guide is a useful reference for the distinction between describing JavaScript and implementing behavior. Pair public exports with a small example consumer, not just an internal unit test.
Use project references when the build has real boundaries
A large tsconfig can hide dependency direction and make every change rebuild everything. Project references make relationships explicit and can support incremental, composite builds. The TypeScript Project References guidance emphasizes separate projects, build mode, and generated outputs. Start when packages have distinct owners, deployment units, or meaningful compilation cost; do not create dozens of projects merely to imitate a diagram. Each reference should have a reason, a clear output, and a test that catches an unintended dependency. A clean build from a fresh checkout is the proof that the graph is complete rather than relying on stale local artifacts.
| Architecture pressure | Useful response | Failure to watch |
|---|---|---|
| Import cycle | Move shared policy to an owned interface or lower layer | Initialization order and unclear ownership |
| Slow rebuild | Split a meaningful project boundary and use references | Stale outputs hiding missing dependencies |
| Runtime mismatch | Align module settings, package metadata, and built tests | Works in source, fails after packaging |
| Wide type export | Expose stable concepts rather than internals | Every implementation change becomes a consumer change |
| Untrusted input | Validate and normalize at the boundary | Unsafe casts spread through the domain |
Prove architecture in tests and delivery
Test more than functions in isolation. Add boundary tests that load the built package, import only its public exports, parse representative external input, and exercise the adapter contract with a failure. Check that a worker can start with production-like module resolution and that a browser bundle does not receive server-only dependencies. Add a dependency-direction check when the graph is important enough to fail loudly. During delivery, retain compiler configuration, package metadata, generated declarations, and build output as inspectable artifacts. A green type check can coexist with a broken runtime if the test never executes the emitted JavaScript.
Work through one boundary-changing example
Suppose a team replaces a direct database call in an order workflow with a remote service. Identify the use-case interface, request and response validation, retry and timeout behavior, error mapping, package export, build dependency, and test fixtures that must change. Keep the domain decision independent of the transport client. Add a contract test for the remote service and a recovery test for a timeout. Then ask another engineer to run the clean build and package import. The related TypeScript architecture guide is useful for comparing boundary choices, while the Node APIs guide covers the runtime edge. If the service persists a new state, cross-check the database schema design guide before publishing the package change.
Keep the architecture changeable
A boundary should make the next change cheaper without hiding important behavior. Record why a module exists, what it owns, which runtime it supports, and what evidence permits a refactor. Remove compatibility shims after consumers migrate; otherwise the public surface grows while the team loses track of which path is authoritative. Review module configuration when upgrading Node.js, TypeScript, bundlers, or package tooling because resolution defaults can change. Measure build time, startup failures, dependency vulnerabilities, and production error categories as architecture signals, not as a substitute for design review.
Rehearse a boundary-changing refactor
A boundary becomes credible when a team can change the implementation behind it without changing the consumer's understanding. Pick a real refactor, such as replacing a database adapter with a remote service or moving a worker into a separate package. List the runtime entry point, input validation, error mapping, timeout, public export, package metadata, project reference, and test fixture that must move together. Build from a clean checkout and execute the emitted artifact. The exercise exposes hidden imports, generated files, environment assumptions, and test doubles that a type check alone does not reveal.
Treat configuration as part of the architecture
Compiler options, package metadata, bundler settings, and runtime flags can change the meaning of a module boundary. Record which configuration is authoritative for each package and test the supported execution modes. Avoid a local path alias that has no equivalent in the emitted package. Keep declaration output and source maps aligned with the public API. When a package supports both development and production modes, prove that the same exports and side effects are available in both rather than relying on the editor's module resolution.
| Refactor evidence | What it proves | Common hidden defect |
|---|---|---|
| Clean build | Dependencies are declared, not cached locally | Stale output masks a missing reference |
| Built-package import | Runtime resolves published surface | Path alias or extension mismatch |
| Boundary input test | External data is validated once | Unsafe cast at a handler |
| Failure test | Adapter errors map to a stable use-case result | Library exception leaks to callers |
Keep the rehearsal result with the architecture decision and repeat it after a module-system or compiler upgrade. The team does not need to freeze tooling; it needs a way to notice when tooling changes the boundary. This is also a useful conversation with product owners: a package split that improves build time is only a success if deployment, debugging, and failure recovery remain understandable.
TypeScript architecture takeaways
- Define runtime, data, dependency, package, and build boundaries before arranging folders.
- Validate external input at the edge; TypeScript types alone do not make runtime data trustworthy.
- Align compiler module resolution with Node.js or the actual production runtime.
- Use explicit exports, declaration files, and small public surfaces to control package coupling.
- Adopt project references when they clarify a real build graph, then prove it with clean packaged tests.
TypeScript architecture FAQ
Are TypeScript types enough for API validation?
No. Types improve the code that follows a boundary, but incoming JSON, environment values, files, and third-party results must be checked at runtime. Convert validated data into an internal type so the rest of the application can rely on an explicit invariant.
Does every monorepo need project references?
No. Use them when packages or build units have meaningful ownership, dependency order, or compilation cost. A smaller repository may be clearer with one project until a real boundary appears; adding references without a purpose adds configuration rather than architecture.
Why should package exports be explicit?
An explicit export map prevents consumers from importing private file paths that later become difficult to move. It gives the team a deliberate public API and lets tests check exactly what a package promises.
Conclusion: let boundaries carry the design
Good TypeScript architecture makes an important distinction visible: what the compiler can assume, what the runtime has verified, and what another package may depend on. Align modules with runtime ownership, validate at the edge, publish stable exports, and make the build graph executable. The payoff is not a fashionable folder tree; it is a codebase in which the next change has a clear place to land and a clear way to prove it is safe.