TypeScript scales when four things are true. The compiler is strict (strict plus noUncheckedIndexedAccess), so the types describe reality instead of hopes. Every value that crosses a trust boundary (requests, environment variables, third-party APIs, the database) is validated at runtime with a schema the types are derived from, because types are erased and prove nothing about incoming data. State is modeled with discriminated unions and exhaustive checks, so impossible states cannot be written. And type checking stays fast enough that nobody is tempted to skip it, through package boundaries, explicit return types on exports, and lint rules that catch what the compiler does not.
Key takeaways
strictis the floor. AddnoUncheckedIndexedAccessandverbatimModuleSyntax, and choose module settings that match how the code actually runs.- Types are only as honest as your boundaries. Validate with a schema at every entry point and infer the type from the schema.
- Use
unknowninstead ofany,satisfiesinstead ofas, and string literal unions instead of enums. - Model state with discriminated unions and make every
switchexhaustive, enforced by the compiler and a lint rule. - Treat type-check time as a production metric for the codebase. Package boundaries and explicit export types keep it flat as the code grows.
A tsconfig baseline that scales
Put shared options in one base config that every package extends:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "preserve",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
}
}What the less obvious flags buy you:
| Flag | What it catches or changes | Cost |
|---|---|---|
strict | Implicit any, unchecked null and undefined, unsafe function types, unknown in catch | None worth arguing about |
noUncheckedIndexedAccess | items[i] and record[key] become possibly undefined, matching runtime | Some extra checks; finds real bugs |
noPropertyAccessFromIndexSignature | Forces obj["key"] for index-signature properties, so dynamic lookups are visible | Minor |
verbatimModuleSyntax | Type-only imports must say import type, and are erased predictably | Small migration |
noImplicitOverride | Subclass methods must say override, so a renamed base method is caught | Small |
exactOptionalPropertyTypes | Distinguishes "missing" from "explicitly undefined" | Noisy with some libraries; good for new code |
skipLibCheck | Skips checking .d.ts files in dependencies | Faster; hides conflicts between dependency typings |
"module": "preserve" (TypeScript 5.4 and later) suits code that a bundler (Vite, Next.js, esbuild) processes. For code Node runs directly, use "module": "nodenext". If you run TypeScript files in Node through type stripping, add "erasableSyntaxOnly": true (TypeScript 5.8 and later), which rejects syntax that needs code generation, such as enums and parameter properties.
Turning strict on in an existing codebase
Do not try to fix every error in one pull request. Keep a tsconfig.strict.json that extends the main config with the strict flags, count its errors in CI, and fail the build if the count rises:
count=$(npx tsc --noEmit -p tsconfig.strict.json | grep -c "error TS" || true)
baseline=$(cat .strict-baseline)
if [ "$count" -gt "$baseline" ]; then
echo "Strict errors rose from $baseline to $count. Fix new code before merging."
exit 1
fiEvery pull request that fixes errors lowers the number in .strict-baseline. New code is strict from day one, old code converges, and when the count reaches zero the strict config becomes the main one. In a monorepo, flip packages one at a time instead.
Validate at the boundaries, infer the types
The most dangerous line in a TypeScript codebase is a cast on external data:
const user = (await res.json()) as User; // a promise, not a checkres.json() returns any. The cast tells the compiler to stop asking. If the API changes shape, the error appears three files later as undefined is not a function. Validate instead, and derive the type from the schema so they cannot drift:
import { z } from "zod";
export const CreateInvoice = z.object({
customerId: z.string().min(1),
currency: z.enum(["USD", "EUR", "INR"]),
dueDate: z.coerce.date(),
lines: z
.array(
z.object({
description: z.string().min(1).max(200),
quantity: z.number().int().positive(),
unitCents: z.number().int().nonnegative(),
}),
)
.min(1),
});
export type CreateInvoice = z.infer<typeof CreateInvoice>;
export async function POST(req: Request) {
const parsed = CreateInvoice.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ error: "invalid_body", issues: parsed.error.issues }, { status: 422 });
}
const invoice = await createInvoice(parsed.data); // typed and validated; dueDate is a real Date
return Response.json(invoice, { status: 201 });
}The boundaries to cover:
- Incoming requests: bodies, query strings, route params, webhook payloads.
- Environment variables: parse
process.envonce at startup and fail fast, so a missing secret crashes the deploy instead of the first request that needs it. - Third-party API responses: validate the fields you use; tolerate the ones you do not.
- Storage:
localStorage, files, queue messages, and JSON columns in the database.
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().int().default(3000),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
});
export const env = Env.parse(process.env);Zod is the common choice; Valibot and ArkType make different size and speed trade-offs, and all three implement the Standard Schema interface, so libraries that accept one usually accept the others. For types that come from a contract (OpenAPI, GraphQL, the database schema), generate them instead of writing them by hand. Our REST vs GraphQL guide covers keeping those contracts as the source of truth.
unknown, satisfies, and the casts you should not write
unknowninstead ofany.unknownforces a check before use;anyturns the checker off for everything it touches, and it spreads through inference.satisfiesinstead of annotations on config objects. It checks the value against a type without widening it, so you keep the precise keys and literals:
type Route = { path: `/${string}`; auth: "public" | "user" | "admin" };
export const routes = {
home: { path: "/", auth: "public" },
billing: { path: "/billing", auth: "admin" },
} satisfies Record<string, Route>;
routes.billing.auth; // type is "admin", and an invalid path or auth value is a compile error- Type guards instead of
as. If you must narrow, write a function that checks:function isApiError(e: unknown): e is ApiError. A cast documents an assumption; a guard tests it. @ts-expect-errorwith a reason, never@ts-ignore.@ts-expect-errorfails when the error it suppresses goes away, so stale suppressions get cleaned up. Require a description with the lint rule below.
Model state with discriminated unions
A set of optional fields (isLoading, data?, error?) allows states that make no sense, such as loading with an error and data at once. A discriminated union makes them unrepresentable:
type RemoteData<T, E = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };
export function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function renderInvoices(state: RemoteData<Invoice[]>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <InvoiceTable invoices={state.data} />;
case "error":
return <ErrorPanel error={state.error} />;
default:
return assertNever(state);
}
}Inside each case, TypeScript narrows state, so state.data exists only on success. The default branch is the important part: add a "stale" status next quarter, and every switch that forgot it fails to compile. The same pattern fits payment states, order lifecycles, form steps, and domain events.
Branded types for values that look alike
userId and orderId are both strings; passing one where the other belongs is always a bug, and the compiler cannot see it. Brands make it visible:
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
export type Cents = Brand<number, "Cents">;
export function toCents(value: number): Cents {
if (!Number.isInteger(value)) throw new RangeError(`Not a whole number of cents: ${value}`);
return value as Cents;
}
declare function getOrder(id: OrderId): Promise<Order>;
declare const userId: UserId;
getOrder(userId); // compile error: UserId is not assignable to OrderIdThe only place a brand is applied is a constructor that validates, which makes the brand mean "this has been checked". Zod's .brand() does the same at a schema boundary. Use brands for IDs, money, and validated strings; do not brand everything, or the ceremony outweighs the benefit.
Errors: return the expected, throw the unexpected
TypeScript cannot track what a function throws, so callers forget to handle failures that are part of normal business. Split failures by kind:
| Failure | Example | Mechanism |
|---|---|---|
| Expected outcome | Card declined, not found, validation failed | Return a discriminated union the caller must handle |
| Broken invariant or bug | Impossible state, missing config | Throw, crash loudly, fix the code |
| Infrastructure failure | Database down, timeout | Throw, catch at the request boundary, log with the cause |
type ChargeResult =
| { ok: true; chargeId: string }
| { ok: false; reason: "card_declined" | "insufficient_funds" | "expired_card" };
async function charge(customerId: string, amount: Cents): Promise<ChargeResult> {
try {
const res = await payments.charge({ customerId, amountCents: amount });
return { ok: true, chargeId: res.id };
} catch (err) {
const reason = declineReason(err);
if (reason) return { ok: false, reason };
throw new Error(`Charge failed for customer ${customerId}`, { cause: err });
}
}The cause option keeps the original error and stack attached when you wrap one, so logs show both. Libraries such as neverthrow add map and andThen helpers to result types; a plain union is often enough.
Lint what the compiler does not check
typescript-eslint's type-aware rules catch whole classes of production bugs that compile cleanly:
// eslint.config.js
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }],
},
},
);no-floating-promises alone justifies the setup: an un-awaited promise that rejects becomes an unhandled rejection, which can crash a Node process or silently drop a write. no-misused-promises catches async functions passed where a void callback is expected, such as an async onClick whose errors vanish.
Keep type checking fast
A type check that takes minutes gets skipped locally, and then CI is the first place errors appear. Treat check time as something you measure.
- Split into packages with project references, or give each package its own cached type-check task. Unchanged packages are not rechecked.
{
"files": [],
"references": [
{ "path": "./packages/domain" },
{ "path": "./packages/api-client" },
{ "path": "./apps/web" }
]
}Each referenced package sets "composite": true, and tsc -b builds them in dependency order, reusing results for packages that did not change.
- Annotate return types on exported functions. Consumers then read a declared type instead of re-inferring through your implementation, and
isolatedDeclarations(TypeScript 5.5 and later) can enforce it so declaration files can be generated in parallel. - Find the expensive types. Measure before guessing:
npx tsc --noEmit --extendedDiagnostics
npx tsc --noEmit --generateTrace ./ts-trace
npx @typescript/analyze-trace ./ts-traceThe trace usually points at a handful of deep conditional types, enormous unions, or a generic helper inferring through a large object. Simplify those; the rest of the codebase is rarely the problem. TypeScript 7, Microsoft's native port of the compiler released in July 2026, attacks checker speed directly and is often about 10 times faster than TypeScript 6. It does not yet expose a stable programmatic API, so tools that embed the compiler can still require TypeScript 6; typescript-eslint, for example, supports TypeScript versions below 6.1. The structural fixes above help regardless of which compiler you run.
Organize types by feature
Co-locate types with the code that uses them and avoid one giant types.ts. Shared types live in a shared package only when two features genuinely depend on them. Use interface for object shapes others extend and type for unions and compositions, and pick one convention for plain object shapes. Use import type for type-only imports (verbatimModuleSyntax enforces it), which keeps runtime import graphs, and accidental circular dependencies, smaller.
What goes wrong in production
The compiler passes; production still breaks. The usual reasons:
- Casts on external data.
as Useron an API response. The API changes, the type does not, and the bug appears far from its cause. Validate at the boundary. - Dates that are strings. A
Dateserialized to JSON comes back as a string, but the type still saysDate. Parse dates in the schema (z.coerce.date()), not with a cast. - Floating promises. A forgotten
awaiton a write, so errors vanish and ordering breaks. Lint for it. - Non-null assertions everywhere. After enabling
noUncheckedIndexedAccess, someone adds!to every index. That is a cast with extra steps. Handle the missing case or restructure the data. - Enums across boundaries. Numeric enums accept any number, and reordering members changes their values in stored data. Prefer string literal unions or
as constobjects. - Types that disagree with the runtime.
@types/nodefor a newer Node than you deploy, or a library's types out of step with its code. Pin@types/nodeto the runtime major you run. - Suppression debt. Hundreds of
@ts-expect-errorcomments that nobody revisits. Track the count like the strict baseline above, and require a reason on each.
Types reduce one class of bug; tests catch the rest. Our testing strategies guide covers how the two layers divide the work.
Frequently Asked Questions
What tsconfig settings should a large TypeScript project use?
Start with strict set to true, then add noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch and verbatimModuleSyntax. Pick module settings that match how the code runs: preserve with bundler resolution for bundled apps, nodenext for code Node runs directly. Share one base config across packages and override only what differs.
Should I use type or interface in TypeScript?
Both work for object shapes, and consistency matters more than the choice. A common rule is interface for object shapes that other code extends, and type for unions, intersections, mapped types and aliases. Only type can express unions, so codebases that model state with discriminated unions end up using type heavily.
Is it ever acceptable to use any in TypeScript?
Rarely, and never silently. Use unknown for values whose type you do not know yet, and narrow them with validation or type guards. Where any is genuinely needed, such as a narrow escape hatch around a badly typed library, isolate it in one small function with a comment, and let a lint rule flag every other use.
Do I still need runtime validation if I use TypeScript?
Yes. TypeScript types are erased at compile time, so they say nothing about data that arrives at runtime from HTTP requests, databases, environment variables, files or third-party APIs. Validate at those boundaries with a schema library such as Zod and derive the TypeScript type from the schema, so the check and the type cannot drift apart.
Should I use enums in TypeScript?
Usually not. A union of string literals, or a const object with as const, gives the same safety with plain JavaScript output and better interop. Enums generate runtime code, numeric enums accept any number, and enums are not erasable syntax, so they do not work when Node runs TypeScript by stripping types.
How do I speed up TypeScript type checking in a large codebase?
Split the code into packages with project references or per-package type-check tasks so unchanged code is not rechecked, add explicit return types to exported functions, and enable skipLibCheck. Then measure with the extendedDiagnostics and generateTrace compiler options to find the specific types that are expensive. Deep conditional types and very large unions are the usual culprits.
Need help with a large TypeScript codebase?
We maintain the TypeScript we write, which is why we care more about honest boundaries and fast checks than clever types. If you are tightening a codebase that has drifted into any, migrating to strict mode without stopping feature work, or hardening an AI-generated prototype for production, talk to the senior engineer who would do the work.