Overview
TypeScript earns its cost only when used strictly. Loose TypeScript with any everywhere is JavaScript with extra build steps. This page covers the strict defaults that make the type system actually help. Read general-principles first.
Always strict: true
Set strict: true in tsconfig.json on day one. Every flag it enables is load-bearing. Disabling individual strict flags (“just strictNullChecks for now”) is a slow-motion downgrade to untyped code.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
}noUncheckedIndexedAccess is the one most teams skip and the one that catches the most bugs: it forces arr[i] to be T | undefined, which is the truth.
No any, use unknown and narrow
any is a hole in the type system. Every value that crosses it becomes an undocumented contract. Use unknown for “I do not know the shape yet,” then narrow with a type guard or a schema parser.
// Bad
function handle(payload: any) { return payload.user.id }
// Better
function handle(payload: unknown) {
const parsed = UserPayload.parse(payload)
return parsed.user.id
}The rare legitimate any (the type from a third-party library is wrong, you are writing a generic helper) goes behind a named alias with a comment explaining why. // @ts-ignore and // @ts-expect-error follow the same rule: explain the why on the same line.
Validate at boundaries with Zod or Valibot
The type system trusts you. Runtime input does not deserve trust. Every byte that crosses a network or filesystem boundary gets parsed by a schema, not cast.
import { z } from "zod"
const Invoice = z.object({
id: z.string().uuid(),
amountCents: z.number().int().nonnegative(),
paidAt: z.coerce.date().nullable(),
})
export type Invoice = z.infer<typeof Invoice>
export async function loadInvoice(id: string): Promise<Invoice> {
const res = await fetch(`/api/invoices/${id}`)
return Invoice.parse(await res.json())
}Use Zod for breadth and ecosystem, Valibot for bundle size. Pick one per project. The schema is the source of truth; derive the static type with z.infer<typeof Schema>.
Prefer narrow types over wide unions
A discriminated union with a tag field beats a flat object with optional fields. The compiler can narrow on the tag; it cannot narrow on “maybe paidAt is set.”
type Invoice =
| { status: "draft"; id: string }
| { status: "sent"; id: string; sentAt: Date }
| { status: "paid"; id: string; sentAt: Date; paidAt: Date }The function that branches on invoice.status gets the right fields in scope automatically. Exhaustiveness check the switch with a never default.
as const for literal preservation
Object and array literals widen to their general types by default. Use as const when the literal shape is the contract.
const ROUTES = {
home: "/",
pricing: "/pricing",
docs: "/docs",
} as const
type RouteKey = keyof typeof ROUTES // "home" | "pricing" | "docs"
type RoutePath = typeof ROUTES[RouteKey] // "/" | "/pricing" | "/docs"This pattern replaces a long tail of hand-typed unions and keeps the values and types in lockstep.
tsconfig hygiene
One tsconfig.json per package. In a monorepo, use project references; do not share a single config across packages. Keep test config and production config separate (tsconfig.test.json extends the base).
- Enable
isolatedModulesif a bundler like esbuild, swc, or Vite consumes the source. - Set
verbatimModuleSyntax: trueto forceimport typefor type-only imports. - Set
noEmit: truewhen a bundler emits the output; tsc only type-checks. - Set
incremental: truefor faster local rebuilds.
ESM only
Ship ES modules. Set "type": "module" in package.json. Import paths include the .js extension (TypeScript resolves the .ts source from the .js import in Bundler mode).
CommonJS is end-of-life in any code written today. The only reason to emit CJS is to ship a library that older Node projects consume; even then, dual-publish via a small wrapper instead of writing CJS source.
For React and Next.js specifics, see react and nextjs. For build and deploy patterns, see astro.