TypeScript is a gradual type system for JavaScript: annotations are checked before the code runs, then erased. What they reach is representation, null, and shape errors, not logic ones; nothing is enforced at runtime. That slice of real bugs has been measured, and so has the absence of an effect on overall defect rates — both in static types.
This page targets TypeScript 6.x on Node LTS. Default tsconfig leaves most of the type system off; the recipe is the ratchet that turns it on.
Setup¶
The strictness ratchet — tsconfig.json¶
Baseline that should be the default but isn't:
{
"compilerOptions": {
// ===== The big one =====
"strict": true,
// ===== Additional strictness flags worth adding day one =====
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
// Unused locals/params — off by default here; the flag notes say why.
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// ===== Module / target =====
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"skipLibCheck": true,
// ===== Output =====
"outDir": "dist",
"declaration": true,
"sourceMap": true,
// ===== Incremental builds =====
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsc/.tsbuildinfo"
},
"include": ["src"]
}
What each non-obvious flag catches:
strictturns onstrictNullChecks,strictFunctionTypes,noImplicitAny,strictPropertyInitialization, and the rest of the strictness family. Without it, the type system is mostly cosmetic.noUncheckedIndexedAccess—arr[i]returnsT | undefinedrather thanT. Catches off-by-one and missing-key bugs the default config silently allows.exactOptionalPropertyTypes—{ x?: number }no longer also accepts{ x: undefined }. Without it, "optional" and "explicitly-undefined" are conflated; with it, they aren't.useUnknownInCatchVariables—catch (e)typeseasunknown, notany. Forces a narrowing step before using the error value.verbatimModuleSyntax— type imports must be markedimport type; the emitted JS contains only the runtime imports. Prevents subtle bundler bugs from side-effecting type imports.noUnusedLocals/noUnusedParameters(commented out in the baseline) —tscflags unused local variables and function parameters with no extra tool. Left off here because they are build-blocking and all-or-nothing: commenting out one line mid-edit fails the type check.@typescript-eslint/no-unused-varscatches the same cases with finer control (fixable,argsIgnorePattern: "^_"for deliberately-unused args — see ESLint). Both are function-scoped; for unused exports and files you need whole-program reachability — see Dead-code detection.incremental+tsBuildInfoFile—tscwrites type-check state to disk and reuses it on the next run, re-checking only files that changed and their dependents. Pays off the moment clean builds take more than a few seconds. Pointing the buildinfo atnode_modules/.cache/keeps it out of source control without an extra.gitignoreentry — wipe-on-clean by virtue of being undernode_modules/. For multi-package monorepos the next step iscomposite: trueplusreferences; out of scope here.
Core patterns¶
These patterns each rule out a category of bug the type system can eliminate by construction.
Sum types and exhaustive matching¶
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "triangle": return (s.base * s.height) / 2;
default: {
const _exhaustive: never = s;
throw new Error(`unhandled: ${_exhaustive}`);
}
}
}
Add a new variant; the compiler fails the never assignment
and lists exactly which switch to update. Pair with
@typescript-eslint/switch-exhaustiveness-check (see
ESLint) for the same check enforced as
a lint rule.
Opaque / branded types for invariants¶
type UserId = string & { readonly __brand: "UserId" };
type Email = string & { readonly __brand: "Email" };
function asUserId(s: string): UserId { return s as UserId; }
function asEmail(s: string): Email { return s as Email; }
function sendInvite(to: Email, from: UserId): void { /* ... */ }
const a = asEmail("hi@example.com");
const b = asUserId("u_123");
sendInvite(a, b); // OK
sendInvite(b, a); // type error — arguments swapped
The brand exists only at the type level — no runtime cost. Construct the branded value once at a boundary (with validation), use it freely after.
as const + satisfies¶
// `as const` narrows to literal types.
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
// `satisfies` checks shape without widening.
const config = {
retries: 3,
endpoints: { api: "/api", auth: "/auth" },
} satisfies { retries: number; endpoints: Record<string, string> };
config.endpoints.api; // type stays the literal "/api", not string
satisfies is the right tool when you want a value to conform
to a shape but keep the narrow types the literal provides.
Result / discriminated-union API modeling¶
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: UserId): Promise<Result<User>> {
try {
const r = await fetch(`/api/users/${id}`);
if (!r.ok) return { ok: false, error: new Error(`HTTP ${r.status}`) };
return { ok: true, value: await r.json() };
} catch (e: unknown) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
Result forces the caller to handle the failure case at the type level.
Zod's .safeParse produces the same shape at parsing boundaries.
Recursive Json type¶
Json is the type for arbitrary JSON inspected at runtime; unlike any, it
keeps the structure checkable.
Boundary validation: Zod¶
The type system stops at the program boundary. For data arriving from the network, the filesystem, environment variables or user input, TypeScript knows only the declared type, not the runtime shape. A runtime parser closes the gap: it produces a typed value or fails.
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().nonnegative().optional(),
});
type User = z.infer<typeof UserSchema>;
// .parse — throws on invalid; use when failure is exceptional.
const u: User = UserSchema.parse(await req.json());
// .safeParse — returns a Result-shaped object; use at boundaries
// where invalid input is a normal case.
const result = UserSchema.safeParse(await req.json());
if (!result.success) {
return new Response(JSON.stringify(result.error.issues), { status: 400 });
}
const user = result.data;
Common patterns:
- HTTP-payload validation —
.parserequest bodies before they touch business logic. - Env-var parsing — define a schema for
process.env, parse once at startup, fail loud on missing vars. - Form parsing —
.safeParseform-data and surfaceresult.error.issuesas field-level errors.
Alternatives:
- valibot — smaller bundle, pipeline-style. Worth the switch when bundle size dominates (edge functions, browsers).
- arktype — faster runtime validation, TypeScript-like syntax in string form. Still under active development.
- io-ts — FP-style with
Either<Errors, A>results. Natural fit if the codebase already uses fp-ts. - Effect.Schema (part of effect.website) — larger-scope alternative covering schema, error handling, and HTTP in one library. Worth the install only if you're buying into the Effect runtime broadly.
For why boundary validation matters, see schema and boundary validation.
Test fixtures from schemas¶
Once a Zod schema exists, fixtures can be generated from it directly:
import { zocker } from "zocker";
import { UserSchema } from "./user.ts";
// One sample.
const sample = zocker(UserSchema).generate();
// A batch — useful as a property-test input source.
const samples = Array.from({ length: 100 }, () =>
zocker(UserSchema).generate(),
);
test("user-handling code accepts any schema-valid user", () => {
for (const u of samples) expect(handleUser(u)).not.toThrow();
});
Alternative: zod-fixture — similar shape, different
randomness controls. Pick one, stick to it.
Generated fixtures are not property-based testing: there is no shrinking, so a failing sample stays as large as it was generated.
Utility / DX libraries¶
Libraries with a narrow, stable footprint:
- type-fest —
npm i -D type-fest. Utility types you'd otherwise hand-roll:Simplify,RequireAtLeastOne,PartialDeep,Tagged(built-in branding helper),Promisable, dozens more. - type-coverage —
npx type-coveragereports the percentage of expressions with non-anytypes. Useful as the delta signal on PRs (did this patch widen theany-shaped hole?) — see Coverage for why delta as signal beats absolute as target. - pretty-ts-errors — VS Code extension; reformats TypeScript's dense error messages into scannable form. Quality-of-life, not correctness.
- typescript-eslint with
recommendedTypeChecked— the type-aware rule subset catches what the compiler doesn't (floating promises, misused promises, exhaustive switches). See ESLint.
End-to-end types¶
End-to-end typing shares TypeScript types between client and server, so a change to one cascades through both. The promise is "refactor an API and the call site fails to compile." The cost is front-end / back-end coupling — the FE can't be deployed independently, the BE can't change shape without breaking the FE, and HTTP-caching semantics (intermediaries, ETags, varied Accept headers) become harder to reason about.
When transparent RPC fits: monorepo, small team, fast iteration, no public API consumers. When it doesn't: multi-team boundaries, public API, third-party consumers, infra that relies on HTTP semantics.
Tools in the space, by shape:
- tRPC — TypeScript-native RPC. No schema layer; client imports the type of the server router and gets typed procedure calls. Best fit when both ends are TypeScript and in the same repo.
- Hono RPC — built into the Hono web framework. Web-standards-based, lighter, less ceremony than tRPC.
- OpenAPI codegen —
openapi-typescript,orval,ts-rest. The right choice when the API is the contract (public, polyglot consumers). The schema is the source of truth; types are generated from it. - React Server Components —
"use server"directive turns a server function into a client-callable. Transparent RPC scoped to one framework; same coupling tradeoff.
Gotchas¶
Type escape hatches¶
any vs unknown. unknown is a value of unknown
type — you must narrow before using. any turns off the
type checker for that expression and everything downstream.
Use unknown at boundaries; reserve any for genuinely
irreducible escape hatches and add a comment. Enforced by
@typescript-eslint/no-explicit-any (forbids the
annotation) plus the no-unsafe-* family in
recommendedTypeChecked (catches any that leaked in
via an untyped dependency or JSON.parse).
as assertions are unchecked. x as Foo tells the
compiler "trust me" with no runtime check. Almost every
use should be either (a) narrowing inside a guard,
(b) constructing a branded type immediately after
validation, or (c) interop with a typed third-party API
that lost its types. Other uses introduce a gap between
the static type and runtime value that the type checker
can't catch. @typescript-eslint/consistent-type-assertions
(with assertionStyle: "never") forbids as outright;
the same package's no-non-null-assertion covers the !
shorthand.
Narrowing and validation¶
Assertion functions vs type guards. A type guard
(x: unknown): x is Foo returns a boolean; an assertion
function (x: unknown): asserts x is Foo throws or
returns void. Use the guard when the caller branches; use
the assertion when the caller assumes success after the call.
JSON round-trips lose nominal types. JSON.parse is
typed as returning any by default. (ts-reset —
@total-typescript/ts-reset — tightens it to unknown,
along with Array.includes, fetch().json, and
Array.filter(Boolean). Install with npm i -D, import
once.) A branded UserId doesn't survive the trip.
Hand-rolled type guards drift from the types they claim to check. A guard's body is not checked against the type it proves, so the two diverge silently.
Module resolution¶
moduleResolution must match the toolchain. bundler vs
node16 / nodenext affect how relative imports and
package.json exports resolve. Pick one based on your
toolchain (Vite / esbuild → bundler; tsc-to-node →
nodenext) and don't mix.
Unsoundness¶
Even with the full strictness ratchet, TypeScript is intentionally unsound — a passing check is evidence, not proof. The strictness flags narrow the gap; they do not close it. TypeScript trades soundness for backward compatibility with the JavaScript ecosystem it had to adopt.
Some holes are avoidable by disciplined code — any, as, JSON boundaries.
The structural ones have no user-land fix:
- Array (and mutable-container) variance.
string[]is assignable to(string | number)[]; the aliased reference canpushanumberinto the originalstring[]. No flag rejects this — mutable containers are unsound under width subtyping.
Walkthrough with runnable examples: Francis Ngo — Understanding TypeScript Unsoundness and Caveats. The wider framing — soundness as a property, TypeScript as the canonical unsound-by-design gradual tier, and the empirical record — is in Static types.
Referenced by¶
- Dead-code detection · Methods
- Static types · Methods
- Playwright · Recipes
- Recipes · Recipes