---
title: "TypeScript: Best Practices"
slug: "typescript"
category: "coding"
tags: ["coding", "typescript", "types", "esm", "validation"]
status: "stable"
last_updated: 2026-05-29
summary: "Strict TypeScript 5.9+ (6.0 shipped): no any, narrow types, runtime validation at boundaries, ESM only, clean tsconfig."
related: ["[[coding/general-principles]]", "[[frontend/react]]", "[[frontend/nextjs]]", "[[frontend/astro]]", "[[coding/typescript-strict-mode]]"]
---

## 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 [[coding/general-principles]] first; for the head-to-head against plain JavaScript, see [[comparisons/typescript-vs-javascript]].

## Always `strict: true`

Set [[coding/typescript-strict-mode|`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.

```json
{
  "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 [[coding/typescript-narrowing|narrow]] with a type guard or a schema parser.

```ts
// 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 [[coding/typescript-runtime-validation|schema]], not cast.

```ts
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."

```ts
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.

```ts
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 [[coding/typescript-tsconfig|`tsconfig.json`]] per package. In a [[file-organization/monorepo|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). For editor-level settings that align VS Code with these compiler options, see [[howto/configure-vscode-for-typescript-projects]].

- 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 [[frontend/react]] and [[frontend/nextjs]]. For build and deploy patterns, see [[frontend/astro]]. When evaluating whether to pull in a utility library, see [[comparisons/lodash-vs-native]] for the Lodash-vs-native trade-offs in a TypeScript codebase.

## Related

- [[coding/general-principles]]
- [[frontend/react]]
- [[frontend/nextjs]]
- [[frontend/astro]]
