Strict configuration, type architecture, monorepo setups, and migration strategies that scale

A small TypeScript project tolerates sloppy typing. A codebase with hundreds of contributors, dozens of packages, and years of accumulated history does not — every any, every loose interface, and every implicit type becomes a liability that someone eventually pays for during a refactor or an incident. The practices below reflect what actually holds up at enterprise scale, not just what compiles.
Enterprise TypeScript should start from the strictest reasonable baseline and loosen deliberately, not the reverse.
strict FlagEnabling "strict": true in tsconfig.json turns on a bundle of checks, including:
strictNullChecks — null and undefined are not silently assignable to other types, eliminating an entire class of runtime null-reference errorsnoImplicitAny — variables and parameters without an inferable type must be explicitly typedstrictFunctionTypes — function parameter types are checked contravariantly, catching unsound function assignmentsstrictPropertyInitialization — class properties must be initialized in the constructor or explicitly marked as possibly undefinedalwaysStrict and noImplicitThis — additional guardrails against common JavaScript footgunsRecommended baseline `tsconfig.json` for new enterprise projects:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
}
}noUncheckedIndexedAccess forces array/object index access to return T | undefined, which is closer to JavaScript's actual runtime behavior than TypeScript's default assumptionexactOptionalPropertyTypes distinguishes between a property being absent and a property explicitly set to undefined, which matters for API contractsisolatedModules ensures every file can be transpiled independently, a requirement for most modern build tools (esbuild, swc) and a precondition for reliable project referencesA strict tsconfig.json is only effective if the build fails on violations. Run tsc --noEmit as a dedicated CI step separate from bundling, since bundlers like esbuild and swc intentionally skip type checking for speed.
Let TypeScript infer types for local variables and internal logic. Be explicit at module boundaries — function signatures, exported types, and API contracts — where inferred types can silently change and break consumers.
Rather than a single interface with many optional fields, model distinct states explicitly:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };This makes invalid states (e.g., data present while status is "error") unrepresentable, which eliminates a common category of bugs where optional-field interfaces allow inconsistent combinations.
A string that holds a user ID and a string that holds an email address are structurally identical to TypeScript, but mixing them up is a real bug class. Branded (nominal) types close this gap:
type UserId = string & { readonly __brand: "UserId" };
type Email = string & { readonly __brand: "Email" };
function getUser(id: UserId) { /* ... */ }In a multi-package codebase, domain types, API contracts, and shared utility types belong in a single @company/types (or similarly named) package that every service depends on — not duplicated or redefined per package, which inevitably drifts.
any: Practical Alternativesany disables type checking entirely for that value and everything derived from it — it is not a neutral placeholder, it's a hole in the type system. Enterprise codebases should treat any as a lint error, not a convenience.
| Instead of | Use | Why |
|---|---|---|
any for unknown external data | unknown | Forces a type check or assertion before use |
any for "don't care" generics | Generic constraints (<T extends object>) | Preserves type safety while allowing flexibility |
any for third-party library gaps | Local .d.ts declaration files | Types the actual shape instead of disabling checking |
any for JSON parsing | unknown + a runtime validation library (Zod, Valibot) | Validates shape at runtime, not just compile time |
any to silence a stubborn error | A narrowly scoped // @ts-expect-error with a comment explaining why | Documents the exception and fails loudly if the underlying issue is later fixed |
Enable the @typescript-eslint/no-explicit-any ESLint rule and treat exceptions as requiring explicit review, not a default escape hatch.
TInput/TOutput is more legible than T/U in a complex signatureextends whenever the function relies on specific properties, rather than leaving them unconstrained and casting internally<T = DefaultType>) for generics that are usually, but not always, a specific type, to reduce call-site verbosityLarge enterprise codebases with multiple internal packages benefit from TypeScript's project references feature, which enables incremental, dependency-aware builds.
Each package gets its own tsconfig.json with "composite": true, and a root tsconfig.json references them:
{
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}Running tsc --build then compiles packages in dependency order and, critically, skips rebuilding packages whose inputs haven't changed — a significant speedup on large codebases compared to a single flat tsconfig.json covering everything.
Project references handle TypeScript's own build graph, but most enterprise monorepos also use a task runner (Turborepo, Nx, or moonrepo) and a package manager workspace feature (pnpm workspaces, or npm/Yarn workspaces) to:
Common setup for a mid-to-large enterprise monorepo:
tsconfig.base.json extended by each package, keeping compiler options consistenttsd or expect-type to assert that generic functions and complex type utilities produce the expected types, in addition to normal runtime unit testsas any defeats the purpose of the type system in exactly the tests meant to catch regressions — prefer typed factory functions for test fixturesMigrating a large existing JavaScript codebase to TypeScript works best incrementally, not as a big-bang rewrite:
.js files using JSDoc annotations before any file is renamed.ts, and count of remaining any usages, give visibility into migration progress for stakeholders| Pitfall | Consequence | Fix |
|---|---|---|
Flat tsconfig.json across a large monorepo | Full rebuilds on every change, slow CI | Adopt project references |
Widespread any from rushed migration | Type safety erodes silently over time | Lint rule + migration tracking |
| Duplicated type definitions per package | Types drift out of sync between services | Shared types package |
| No runtime validation at API boundaries | Type errors don't prevent bad data at runtime | Schema validation library |
| Overly generic utility types | Hard-to-read error messages, slow IDE performance | Constrain generics, avoid unnecessary abstraction |
At small scale, the cost of loose typing is negligible. At enterprise scale — many teams, many services, years of history — the compounding effect is severe: refactors that should take an hour take days because the type system can't be trusted to catch what breaks, onboarding new engineers takes longer because types don't document intent, and production incidents trace back to type gaps (any, unchecked API responses, loosely modeled state) that strict configuration would have caught at compile time.
Should every enterprise project enable `strict: true` immediately?
For new projects, yes. For migrating an existing loosely typed codebase, enable strict mode incrementally, flag by flag, rather than all at once — a sudden jump to full strict mode on a large legacy codebase typically surfaces thousands of errors and stalls the migration.
Is `unknown` always better than `any`?
For values coming from outside the type system's control — API responses, JSON.parse results, third-party libraries without types — yes. unknown forces a check before the value can be used, while any disables checking entirely.
Do project references slow down small projects?
For small, single-package projects, project references add unnecessary configuration overhead. They earn their complexity specifically in multi-package monorepos where incremental builds and clear dependency boundaries matter.
How do teams keep type definitions consistent across microservices?
Most enterprises centralize shared contract types in a dedicated package, or generate types automatically from a single source of truth such as an OpenAPI spec or a shared schema definition, rather than hand-maintaining matching types in each service independently.
Does TypeScript eliminate the need for runtime type validation?
No. TypeScript types are compile-time only and are erased before the code runs, so they provide zero protection against malformed data arriving from outside the program (network requests, user input, files). Runtime schema validation remains necessary at every external boundary.
TypeScript best practices for enterprise applications are less about individual syntax tricks and more about system-level discipline: strict compiler settings enforced in CI, deliberate type architecture that makes invalid states unrepresentable, a monorepo build graph that scales with the codebase, and a firm stance against any as a default. Codebases that adopt these practices early — or migrate to them deliberately — spend far less time fighting their own type system and far more time relying on it.