TypeScript is great at small scale. You add a few interfaces, turn on some checks, and everything feels safer almost immediately. But drop that same casual approach into a codebase with hundreds of files, a dozen contributors, and years of accumulated decisions, and things start to creak: the anys multiply, the IDE slows to a crawl, and nobody agrees on where a type should live.
None of this is inevitable. It just means large projects need more deliberate conventions than small ones do. Here's what actually helps.
1. Get the Project Structure Right Early
Structure decisions are cheap on day one and expensive on day two hundred. Two broad approaches dominate:
-
By type -
components/,services/,types/,utils/ -
By feature/domain -
features/billing/,features/auth/, each with its own components, types, and logic
For large projects, feature-based structure almost always wins. It keeps related code together, makes it obvious where new code belongs, and lets teams own a folder instead of stepping on each other across five different top-level directories.
Your tsconfig.json also deserves real attention, not the defaults left over from create-* scaffolding:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"]
}
}
}
Path aliases prevent the ../../../../shared/utils problem, and if you're in a monorepo, TypeScript's project references let you split the codebase into independently buildable units which matters a lot once a full tsc run takes longer than your coffee break.
2. Turn On Strict Mode All of It
strict: true isn't one setting; it's a bundle of them, and each one closes a real hole:
-
noImplicitAny- stops untyped values from silently becomingany -
strictNullChecks- forces you to handlenull/undefinedexplicitly -
noUncheckedIndexedAccess- makes array/object index access returnT | undefinedinstead of pretending it's always safe
If you're retrofitting strictness onto an existing large codebase, don't flip the switch and fix 4,000 errors in one PR. Enable strict in a new tsconfig.strict.json that extends the base config, apply it file-by-file or folder-by-folder, and track the migration as a checklist rather than a big-bang change.
3. Design Types, Don't Just Declare Them
At scale, how you model your types matters as much as whether you type things at all.
Interfaces vs. types: use interface for object shapes that might be extended (especially public APIs), and type for unions, intersections, and anything that isn't a plain object shape. Consistency matters more than the specific rule you pick write it down in your style guide.
Ban any, embrace unknown: any disables type checking entirely; unknown forces you to narrow before use. If a value's shape is genuinely unknown (parsing JSON, handling catch blocks), unknown gives you safety without lying to the compiler.
Discriminated unions for state: instead of a loading: boolean and error: string | null living independently (and allowing invalid combinations like both being truthy), model state as one union:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
Now impossible states are actually impossible to represent.
Lean on utility types: Partial, Pick, Omit, and Record exist specifically to avoid redefining a type slightly differently three times. If you catch yourself copy-pasting an interface and removing two fields, reach for Omit instead.
4. Manage Shared Types Deliberately
In a large codebase, "where does this type live" becomes a real question. A reasonable default: types used by a single feature stay co-located with that feature; types used across three or more features move to a shared location.
If your frontend consumes a backend API, don't hand-write those types generate them. Tools like openapi-typescript or GraphQL codegen keep your types synced with the actual contract, so a backend change surfaces as a compile error instead of a runtime surprise three weeks later.
Watch for circular type dependencies too they're a common side effect of scattering related types across too many files, and they tend to show up as confusing TypeScript errors that don't point at the real problem.
5. Set Boundaries in Your Code Organization
Barrel files (index.ts re-exporting everything from a folder) feel convenient but come with real costs at scale: they can create circular imports, and they force bundlers and the TypeScript compiler to resolve far more than necessary, which shows up as slower builds and slower IDE autocomplete. Use them sparingly, and prefer direct imports for anything performance-sensitive.
More importantly, decide on a dependency direction and enforce it typically something like domain → application → UI, where inner layers never import from outer ones. eslint-plugin-boundaries or dependency-cruiser can enforce this automatically instead of relying on code review to catch violations.
6. Automate What You Can
Large teams can't rely on individual discipline alone. A few things worth wiring into CI:
-
typescript-eslintrules likeno-explicit-any,no-floating-promises, andconsistent-type-imports - Type-checking in CI, separate from your test suite, so a red build means a red build not a flaky test masking a type error
-
Pre-commit hooks (via
husky+lint-staged) to catch obvious issues before they hit a PR at all
For build performance specifically, enable incremental builds ("incremental": true), use project references to avoid rechecking unrelated packages, and set skipLibCheck: true so TypeScript doesn't re-verify every node_modules .d.ts file on every build.
7. Type Your Tests Too
Tests are code, and untyped test code rots just as fast as untyped application code.
- Type your mocks and test utilities properly instead of casting everything with
as any - Avoid type assertions in test setup where possible if you need to force a type, it's often a sign the function under test needs a better signature
- Use typed factory functions for test data (
createMockUser(overrides?: Partial<User>)) instead of scattering hand-built objects across dozens of test files
8. Common Pitfalls Worth Naming
A few patterns show up again and again in large TypeScript codebases:
- Generic overengineering - a type with five generic parameters and three conditional branches usually isn't more correct, just harder to read. Reach for generics when you need reusable, type-safe abstractions - not by default.
- Type duplication across services - the same "User" shape redefined slightly differently in four different packages. This is usually a sign you need a shared types package (or codegen from a single source of truth).
-
IDE slowdown - large union types, deeply nested generics, and excessive barrel exports all contribute. If autocomplete starts lagging, it's worth profiling with
tsc --extendedDiagnosticsbefore just living with it.
Wrapping Up
None of this is exotic. It's mostly about making decisions early strictness level, file structure, where types live, what gets automated and writing them down so the whole team follows the same rules instead of each contributor improvising their own.
TypeScript's value compounds at scale when the conventions are consistent. It erodes just as fast when they aren't. Start with strict mode, structure by feature, automate your linting and type-checking in CI, and revisit the rest as your codebase actually runs into it.
What's been the biggest TypeScript pain point on your own large projects? I'd love to hear what didn't make this list.
TypeScript builds on JavaScript, so it's worth keeping up with the language itself too check out JavaScript ES2026: New Features Every Developer Must Know for what's new
Top comments (0)