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 isolatedModules if a bundler like esbuild, swc, or Vite consumes the source.
  • Set verbatimModuleSyntax: true to force import type for type-only imports.
  • Set noEmit: true when a bundler emits the output; tsc only type-checks.
  • Set incremental: true for 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.