Best Practices for Structuring Large TypeScript Projects
Introduction
As TypeScript codebases grow beyond a handful of files, simple folder dumping and ad-hoc imports quickly turn into maintenance nightmares: brittle build pipelines, slow IDE responses, circular dependencies, ambiguous module boundaries, and fragile runtime behavior. For intermediate developers, the challenge isn't just learning TypeScript's types — it is designing a project structure, tooling, and workflows that scale with teams and features.
In this comprehensive guide you'll learn practical, battle-tested techniques to structure large TypeScript projects for clarity, maintainability, and performance. We'll cover logical architecture patterns, tsconfig organization, module boundaries, monorepo vs polyrepo trade-offs, build and CI strategies, declaration file generation, interop with JavaScript and CommonJS, advanced compiler flags, testing layout, and strategies to avoid common pitfalls.
Expect step-by-step examples, code snippets, and actionable configuration notes. By the end you'll have a clear checklist and templates to apply to your next medium-to-large TypeScript codebase — whether you're moving from plain JavaScript, splitting a monolith, or optimizing an existing TypeScript system.
What you will learn:
- How to design folder & module layouts that communicate intent
- How to configure tsconfig and compiler flags for speed and correctness
- When to emit declaration files and how to manage them
- How to structure builds and CI to catch regressions early
- Patterns for cross-package sharing, testing, and migration
Let's get practical.
Background & Context
Large TypeScript projects present three core dimensions that you must balance: developer ergonomics, build/runtime performance, and type correctness. Developer ergonomics means fast editor feedback, discoverable APIs, and clear boundaries between layers (domain, infrastructure, UI). Performance encompasses both incremental builds and CI-oriented full builds. Type correctness ensures your teams catch bugs early without introducing too much friction.
TypeScript's compiler options are central to achieving these aims: settings like incremental builds, isolatedModules, and declaration generation change both behavior and tooling requirements. Organizing your code into packages, using clear export surfaces, and employing automated checks in CI will keep the codebase healthy. This guide assumes an intermediate understanding of TypeScript syntax and basic tooling (npm/Yarn, a code editor like VS Code), and focuses on architecture and configuration decisions that scale.
Key Takeaways
- Design shallow, intention-revealing folder structures and explicit public APIs
- Use tsconfig inheritance and project references for fast builds and clear boundaries
- Emit declaration files for published packages, and generate them reliably in CI
- Prefer isolatedModules-safe patterns when using Babel/ESBuild; rely on TypeScript for full type checks
- Configure strict type checks (strictNullChecks, exactOptionalPropertyTypes) incrementally
- Use CI checks to enforce consistent file casing and import interop rules
- Structure tests near code and use typed test helpers to avoid duplication
Prerequisites & Setup
Before you begin, ensure you have the following:
- Node.js LTS installed and a package manager (npm, Yarn, or pnpm)
- TypeScript compiler installed as a dev dependency: 'npm install -D typescript'
- Familiarity with package.json, basic npm scripts, and your editor's TypeScript integration
- A source-control system (git) and a CI provider (GitHub Actions, GitLab CI, etc.)
You should also be comfortable reading and editing tsconfig.json files. If you want to learn more about how TypeScript organizes compiler options, our primer on tsconfig.json compiler option categories is a useful companion.
Main Tutorial Sections
1) Organize by Domain, Not by Type
Instead of grouping files by type (components, services, utils), structure top-level folders by domain or feature (auth, billing, search). This reduces cross-cutting imports and clarifies ownership.
Example layout:
/apps /web /admin /packages /core (domain models) /api-client /ui-kit /tools /scripts
Each package should expose a clear public API (index.ts) and keep internal helpers in an internal folder. Limit deep import paths using package entry points to prevent accidental dependence on internals.
When designing package boundaries, think about explicit contracts, not file layout.
2) Use TypeScript Project References for Fast Builds
Project references let the TypeScript compiler understand package boundaries and do incremental builds across multiple tsconfig.json files. This is especially helpful in monorepos.
Example tsconfig references in the monorepo root:
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/api-client" }
]
}Each package then has its own tsconfig.json with "composite": true. This enables 'tsc -b' builds that are fast and reproducible.
3) Centralize Shared Types and Utilities
Create a 'core' or 'types' package for shared domain types and utility functions that should be consumed by other packages. Keep it narrow to avoid dependency loops.
Code example for an exported type in packages/core/src/index.ts:
export type UserId = string;
export interface User {
id: UserId;
name: string;
}Avoid leaking implementation details in the shared package; expose only what other packages require.
4) Configure tsconfig.json Strategically
Use tsconfig inheritance to define base settings for all packages and override as needed. A typical base includes strict mode flags, target, module, and common paths.
Example base tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true
}
}If you need deeper guidance on individual flags like esModuleInterop and allowSyntheticDefaultImports, read our guide on Configuring esModuleInterop and allowSyntheticDefaultImports.
5) Decide on Declaration Files Strategy
If packages are published to npm or consumed by JS-only projects, generate declaration files (.d.ts). Use 'declaration': true and 'declarationMap': true for better DX.
Example package tsconfig:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}Automate generation with a build pipeline. For a detailed walkthrough, see our guide on Generating Declaration Files Automatically (declaration, declarationMap).
6) Handle Mixed JS/TS Codebases Carefully
If you must allow JavaScript files, enable 'allowJs' and optionally 'checkJs' to incrementally add TypeScript checks. Prefer migrating small, well-scoped modules rather than converting everything at once.
Example tsconfig snippet:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false
}
}When migrating, progressively enable 'checkJs' for directories you fully converted. For an in-depth migration guide, see Allowing JavaScript Files in a TypeScript Project (allowJs, checkJs) — Comprehensive Guide.
7) Keep Transpilation Predictable with isolatedModules
If your build pipeline uses Babel or esbuild for transpilation, ensure you write code compatible with isolatedModules: avoid const enums and certain namespace patterns. 'isolatedModules' forces TypeScript to only use syntax-transformable features.
Enable it when using non-TS transpilers:
{
"compilerOptions": {
"isolatedModules": true
}
}For more on how isolatedModules affects transpilation safety and migration, consult Understanding isolatedModules for Transpilation Safety.
8) Enforce Consistent File Casing and Import Paths
Different environments (macOS vs Linux) have different file system casing behaviors. Enforce consistent path casing to prevent CI failures using lint rules or a pre-commit hook. Mis-cased imports can be silently tolerated locally but fail on Linux CI.
Add a CI check or pre-commit step to detect mismatches. For step-by-step guidance, see our article on Force Consistent Casing in File Paths: A Practical TypeScript Guide.
9) Build & CI: No-Emit vs No-Emit-On-Error and Incremental Checks
A robust CI pipeline includes: linting, type checks, unit tests, and a release build. Decide whether to abort emits on error. 'noEmitOnError' prevents output when type errors exist; 'noEmit' prevents any emit (useful for CI that uses separate build steps).
Typical CI script:
# install npm ci # lint npm run lint # type check tsc -b --noEmit # tests npm test # build for release tsc -b
For more details on when to use 'noEmitOnError' vs 'noEmit', see Using noEmitOnError and noEmit in TypeScript: When & How to Control Emitted Output.
10) Make Type Strictness Intentional and Incremental
Enable 'strict' modes for long-term quality, but when migrating, opt for a gradual approach: enable 'strictNullChecks' per package or folder, and lock down optional property behavior with 'exactOptionalPropertyTypes' when ready.
Use this incremental pattern:
- Turn on 'noImplicitAny' and 'strictNullChecks' in a small package
- Fix errors, add tests
- Gradually apply to other packages
To learn migration strategies, see Configuring strictNullChecks in TypeScript: A Practical Guide for Intermediate Developers and Configuring Exact Optional Property Types (exactOptionalPropertyTypes) in TypeScript.
Advanced Techniques
Once your base structure is stable, consider advanced techniques that improve performance and developer experience. First, use incremental TypeScript builds with project references to dramatically reduce CI time. Second, adopt a typed API surface and keep implementation details internal — this keeps compilation cheaper for dependents.
Use a build orchestration tool (e.g., turborepo, nx) to parallelize compilation tasks and cache outputs. Pair these with declarationMap and composite builds for faster local dev rebuilds. Consider using isolatedModules + Babel/esbuild for faster transpilation during development while still running 'tsc -b' in CI for full type correctness.
Lastly, invest in ergonomics: editor plugins that resolve path aliases, CI checks that run 'tsc --build', and test harnesses with type-aware mocks. These small improvements compound into large developer-time savings.
Best Practices & Common Pitfalls
Dos:
- Do define a single public entrypoint per package and avoid internal deep imports.
- Do use tsconfig inheritance and project references for clear boundaries and fast builds.
- Do run type checks and lint in CI, not just locally.
- Do generate declaration files for published packages.
Don'ts and pitfalls:
- Don’t rely on implicit any or lax strict settings; they hide bugs.
- Avoid circular dependencies between packages — they slow builds and cause runtime surprises.
- Don’t mix default/CommonJS interop assumptions across packages; be explicit with esModuleInterop where necessary. See the guide on esModuleInterop and allowSyntheticDefaultImports for best practices.
- Avoid platform-specific filename casing issues by enforcing file-case consistency early; follow patterns in Force Consistent Casing in File Paths: A Practical TypeScript Guide.
Troubleshooting tips:
- If 'tsc' is slow, enable incremental builds or split into references.
- If editor type errors differ from CLI, ensure your editor uses the workspace TypeScript version and not a bundled one.
- If Babel/ESBuild transpile but types fail in CI, enable 'isolatedModules' and/or use 'tsc -b' in CI for full checks.
Real-World Applications
- SaaS Product with separate web and admin UIs: Use a monorepo layout with shared 'core' and 'ui-kit' packages. Each UI app imports only public APIs and CI runs 'tsc -b' for the whole repository.
- Internal Libraries: Publish type-safe, narrow-surface npm packages from packages/ with declaration files and automated changelogs.
- Gradual Migration: Start by enabling 'allowJs' and 'checkJs' for safe conversion, then move to isolated TypeScript packages as you convert.
In each case, the combination of clear package boundaries, tsconfig references, typed build artifacts, and consistent CI checks ensures stable evolution as the team and codebase scale.
Conclusion & Next Steps
Structuring large TypeScript projects is more about discipline and patterns than clever hacks. Start with domain-driven folders, clear package boundaries, and rigorous tsconfig setups. Use project references and declaration generation to speed builds and distribute typed packages. Establish CI checks for linting, consistent casing, and full type builds. Incrementally harden type strictness and prefer explicit public APIs.
Next steps: apply the patterns above to a single package, add project references, and automate declaration generation in CI. Explore the linked deep dives for specific flags and migration strategies.
Enhanced FAQ
Q1: When should I use project references vs a single tsconfig for the whole repo?
A1: Use project references when your repo contains multiple independent packages or when builds are slow. References allow incremental builds and explicit boundaries, avoiding recompiles of unchanged packages. If the repo is small (a single app with a few hundred files), a single tsconfig may suffice. For monorepos, references scale much better.
Q2: How do I prevent circular dependencies between packages?
A2: Design small, narrowly focused packages and centralize shared types/utilities in a single 'core' package. Enforce dependency rules with linting or tools like madge. If you encounter cycles, refactor shared logic to a lower-level package that both can depend on.
Q3: Should I enable 'allowJs' in a migration?
A3: Enable 'allowJs' if you need to intermix JS and TS during migration. Set 'checkJs' to false initially and enable it progressively as you convert modules. For a thorough migration plan, reference Allowing JavaScript Files in a TypeScript Project (allowJs, checkJs) — Comprehensive Guide.
Q4: What's the correct way to generate declaration files for packages?
A4: Enable 'declaration' and 'declarationMap' in your package's tsconfig and ensure 'composite' is true when using project references. Use 'outDir' to place artifacts under dist/ and automate generation in CI. See Generating Declaration Files Automatically (declaration, declarationMap) for a step-by-step walkthrough.
Q5: How do I handle third-party CommonJS modules with default imports?
A5: If you're importing CommonJS modules that export via 'module.exports', enable 'esModuleInterop' or 'allowSyntheticDefaultImports' to simplify imports. Be intentional and consistent across packages. Our guide on Configuring esModuleInterop and allowSyntheticDefaultImports explains the differences and best practices.
Q6: How can I avoid platform-specific import path issues?
A6: Enforce consistent file path casing via linters or CI checks. Pre-commit hooks can catch mismatches. For an applied guide, check Force Consistent Casing in File Paths: A Practical TypeScript Guide.
Q7: Is 'isolatedModules' required if I use Babel or esbuild?
A7: When using Babel or esbuild to transpile TypeScript, enabling 'isolatedModules' ensures your code only relies on syntax-level transformations that those tools support. If you rely on TypeScript-only transforms (like const enums), they will fail. For more on this, read Understanding isolatedModules for Transpilation Safety.
Q8: How should I enable strict type checks without overwhelming the team?
A8: Adopt a gradual approach: enable stricter flags per package or folder, fix errors, and set guards for new code. Start with 'noImplicitAny' and 'strictNullChecks', and later enable 'exactOptionalPropertyTypes'. Useful migration tips are available in Configuring strictNullChecks in TypeScript: A Practical Guide for Intermediate Developers and Configuring Exact Optional Property Types (exactOptionalPropertyTypes) in TypeScript.
Q9: Where should tests live in a large TypeScript project?
A9: Co-locate unit tests with the code they exercise to keep context close. For shared integration/end-to-end tests, place them in a top-level test folder. Keep test helpers in a test-utils package to encourage reuse without polluting public package APIs.
Q10: What tooling improves incremental build speed the most?
A10: Project references and 'tsc -b' provide huge wins. Add a build orchestrator (turborepo, nx) for caching and parallelism. Use 'declarationMap' and composite builds for faster local workflows. Keep compilation units small and focused to minimize churn.
Additional resources
- For a practical introduction to key tsconfig flags and categories, see tsconfig.json compiler option categories.
Implement these patterns iteratively and measure developer velocity and build times after each change. Small, consistent improvements compound into a resilient, scalable TypeScript codebase.
