Why “good enough” TypeScript fails at scale
TypeScript makes individual developers productive quickly—but large teams suffer when the codebase becomes a loose federation of styles, types, and “temporary” anys. At 5–10 engineers, tribal knowledge fills gaps. At 30+, it becomes a tax: slower reviews, brittle refactors, inconsistent patterns, and production bugs caused by mismatched assumptions.
The goal isn’t “more types.” The goal is predictable code: consistent constraints, fast feedback, and safe changes across teams.
Turn on strictness (and keep it on)
If your team is large, strict: true isn’t optional. It’s the closest thing TypeScript has to organizational memory.
Baseline tsconfig.json recommendations:
"strict": true"noImplicitAny": true"strictNullChecks": true"noUncheckedIndexedAccess": true(reduces “undefined surprises”)"exactOptionalPropertyTypes": true(prevents sloppy optionals)"useUnknownInCatchVariables": true
If you’re migrating a legacy codebase, don’t disable strictness globally. Use incremental adoption:
- Isolate legacy code behind boundaries (adapters).
- Use
@ts-expect-error(not@ts-ignore) with a comment and an issue link. - Add “type debt” checks in CI (e.g., count of
@ts-expect-errormust not increase).
Opinionated take: allowing new any in a mature codebase is like allowing new global variables—everyone regrets it later.
Establish a shared project structure and module boundaries
Large teams need predictable paths:
src/for production code,test/or__tests__/for testsdomain/(business types + invariants)services/(IO + integrations)ui/orcomponents/(React)utils/only for truly generic helpers (keep it small)
Enforce boundaries with tooling:
- TypeScript project references for monorepos (faster builds, clearer ownership).
- Path aliases (
baseUrl,paths) to avoid brittle relative imports. - ESLint rules (or Nx/module-boundaries) to prevent cross-layer imports.
Principle: make it easy to do the right thing, hard to do the wrong thing.
Prefer explicit, stable public types
In large codebases, the main risk isn’t internal complexity—it’s public API drift between packages, modules, or teams.
Best practices:
- Create dedicated
types.ts(orpublic.ts) for exported types. - Don’t export inferred types accidentally from implementation files.
- Use
export type { ... }to keep type-only exports clear.
Example: avoid exporting “whatever the function returns” if it’s part of a contract.
Bad:
export const getUser = async () => ({ ... })(return type inferred, changes silently)
Better:
export async function getUser(): Promise<UserDto> { ... }
This is especially important for SDKs, shared packages, and “platform” teams.
Model domains with discriminated unions (not Boolean soup)
Large teams commonly accumulate “status flags” that contradict each other. Use discriminated unions to make invalid states unrepresentable.
Example pattern:
type Loadable<T> = { status: 'idle' } | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'ready'; data: T }
This improves readability, makes reducers safer, and gives exhaustiveness checking (especially with switch + never). It also makes onboarding easier—developers see the valid states immediately.
Use runtime validation at boundaries
TypeScript types don’t exist at runtime. In large teams, data crosses boundaries constantly: HTTP, message queues, localStorage, third-party SDKs.
Rule: validate all untrusted input at the boundary, then treat it as typed internally.
Practical options:
- Zod, Valibot, or io-ts for schema validation
- Generate types from OpenAPI/GraphQL and validate server responses when reliability matters
Pattern:
- Parse/validate in
services/. - Convert external DTOs into internal domain types.
- Don’t leak “API shapes” across the app.
This is the difference between “typed” and “correct.”
Standardize linting, formatting, and type-aware ESLint
Teams waste time debating style in reviews. Make it automatic.
Minimum stack:
- Prettier for formatting
- ESLint with TypeScript support (
@typescript-eslint) - Enable type-aware lint rules (requires
parserOptions.project)
A few high-leverage rules:
- Ban
ts-ignore; allowts-expect-erroronly with justification - Prefer
unknownoverany - Catch floating promises (
no-floating-promises) - Enforce consistent type imports (
consistent-type-imports)
Add pre-commit hooks (lint-staged) and CI checks. If CI is the only enforcer, drift is guaranteed.
Agree on “when to use interface vs type” (and write it down)
This debate becomes noise unless you standardize.
A pragmatic team convention:
- Use
typeby default (unions, intersections, utility types) - Use
interfacefor public, extendable object shapes (especially library-like surfaces)
More important than the choice is consistency. Pick one default, document exceptions.
Keep generics readable and localized
Generics are powerful, but over-generic code becomes a shared burden.
Guidelines:
- Keep generic parameters close to where they’re used.
- Prefer descriptive names (
TData,TParams) overT/Uin shared utilities. - Don’t build “frameworks” unless multiple teams already need them.
If a helper requires 3+ generic parameters to be usable, it’s likely too abstract for a product codebase.
Make refactors safe with “type tests” and build pipelines
As teams scale, refactors happen constantly: renames, new API versions, package splits.
Tactics:
- Run
tsc --noEmitin CI (separate from bundler builds) - Add API extractor / dts checks for shared packages
- Use “type tests” with
tsdorexpectTypeOffor critical library surfaces
The goal is to detect breaking changes before runtime—and before other teams feel them.
Document patterns as “golden paths”
A large team doesn’t need more rules; it needs default solutions.
Write short internal docs for:
- How to add a new module/package
- How to model API responses (DTO → domain mapping)
- Error handling conventions
- State modeling patterns (unions, reducers)
- When it’s acceptable to use
any(ideally: almost never)
Pair docs with templates or generators so new code starts in the right shape.
Conclusion: TypeScript is a coordination tool
For large teams, TypeScript’s biggest win isn’t fewer bugs—it’s reduced coordination cost. Strict compiler settings, clear module boundaries, stable public types, and runtime validation create a codebase where engineers can move independently without stepping on each other.
If you want one mantra to guide decisions: optimize for safe change. Everything else—velocity, quality, onboarding—follows from that.