Software Engineering Wiki

Languages

TypeScript

Type narrowing, generics and utility types, tsconfig settings that change what the compiler catches, and the runtime boundary.

Cheatsheet #

TaskCommand or syntax
Type-check onlytsc --noEmit
Watchtsc --noEmit --watch
What type is thishover, or type T = typeof x
Narrow an unknowntypeof, instanceof, in, or a type predicate
Exhaustive switchdefault: const _x: never = value
Freeze literal typesas const
Key of a typekeyof T
Value types of a typeT[keyof T]
Make optionalPartial<T>
Make requiredRequired<T>
Subset of keysPick<T, 'a' | 'b'>
Remove keysOmit<T, 'a'>
Function’s return typeReturnType<typeof fn>
Awaited valueAwaited<ReturnType<typeof fn>>
Runtime validationzod, valibot, or a hand-written predicate

Types describe compile time only #

Every type disappears at runtime. as is an assertion, not a check: it silences the compiler and changes nothing about the value. Anything crossing a boundary — HTTP responses, JSON.parse, environment variables, database rows — is unknown until something validates it.

const data = JSON.parse(body) as User;     // a lie the compiler believes

const parsed = UserSchema.safeParse(JSON.parse(body));   // zod: checked at runtime
if (!parsed.success) throw new BadRequest(parsed.error.message);
const user: User = parsed.data;            // now the type is earned
function isUser(v: unknown): v is User {   // hand-written predicate
  return typeof v === 'object' && v !== null && 'id' in v && typeof (v as User).id === 'number';
}

Narrowing #

function handle(input: string | number | null) {
  if (input === null) return;              // null excluded below
  if (typeof input === 'string') return input.toUpperCase();
  return input.toFixed(2);                 // number
}

if (err instanceof HttpError) { err.status }
if ('body' in res) { res.body }

type Result =
  | { status: 'ok'; data: User }
  | { status: 'error'; message: string };

function render(r: Result) {
  switch (r.status) {
    case 'ok': return r.data.name;         // discriminated union narrows both branches
    case 'error': return r.message;
    default: { const _never: never = r; return _never; }   // fails to compile if a case is added
  }
}

Discriminated unions plus an exhaustive switch are the main reason to use TypeScript at all: adding a variant produces a compile error at every place that must change.

Generics #

Constrain, do not widen. A generic without a constraint tells you nothing about what is allowed.

function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  return Object.fromEntries(keys.map(k => [k, obj[k]])) as Pick<T, K>;
}

async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  let last: unknown;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(); } catch (e) { last = e; await sleep(2 ** i * 100); }
  }
  throw last;
}
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
type Handler<E extends string> = `on${Capitalize<E>}`;        // template literal type
type Unwrap<T> = T extends Promise<infer U> ? U : T;          // conditional + infer

Keep type-level programming to what pays for itself. A conditional type nobody can read is worse than a slightly repetitive definition.

tsconfig #

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "declaration": true,
    "outDir": "dist"
  },
  "include": ["src"]
}
OptionWhat it catches
strictUmbrella: null checks, implicit any, function variance, this
noUncheckedIndexedAccessarr[0] is T | undefined — the single highest-value extra flag
exactOptionalPropertyTypesDistinguishes “absent” from “set to undefined”
isolatedModulesEnsures each file transpiles alone, required by esbuild/swc
verbatimModuleSyntaxMakes import type explicit, avoiding phantom runtime imports
skipLibCheckSkips checking .d.ts files: faster, and usually the right trade

Type-checking and transpiling are separate jobs now: tsc --noEmit in CI, esbuild/swc/tsup for output. That is why isolatedModules matters.

Async #

const [a, b] = await Promise.all([fetchA(), fetchB()]);          // fails fast
const results = await Promise.allSettled([fetchA(), fetchB()]);  // never rejects
const first = await Promise.race([work(), timeout(5000)]);

const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
const res = await fetch(url, { signal: ac.signal });

catch (e) gives unknown under strict — narrow before using it:

try { await work(); }
catch (e) {
  if (e instanceof Error) log.error(e.message, { stack: e.stack });
  else log.error('unknown failure', { value: String(e) });
}

An async function that is called without await and without .catch() produces an unhandled rejection, which kills the Node process by default. void promise documents that you meant it; await documents that you needed it.

Structuring types #

// Prefer type aliases for unions and functions, interfaces for object contracts others extend
type Method = 'GET' | 'POST';
interface Store { get(id: string): Promise<User | undefined>; }

// Branded types stop mixing up identifiers of the same primitive shape
type UserId = string & { readonly __brand: 'UserId' };
const asUserId = (s: string): UserId => s as UserId;

// Readonly for data that should not be mutated downstream
function sum(values: readonly number[]): number { return values.reduce((a, b) => a + b, 0); }

// as const for literal inference
const ROLES = ['admin', 'user'] as const;
type Role = (typeof ROLES)[number];    // 'admin' | 'user'

enum has runtime semantics and awkward interop; a const object plus a derived union does the same with less surprise.

Node specifics #

import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

const port = Number(process.env.PORT ?? 8080);
if (!Number.isInteger(port)) throw new Error('PORT must be an integer');

Environment variables are string | undefined regardless of what the types say — validate them once at start-up rather than checking at every use.

Oneliners #

# Type-check without emitting, in CI
npx tsc --noEmit --pretty false

# Which files are slowing the build
npx tsc --noEmit --extendedDiagnostics | head -20

# See the resolved config
npx tsc --showConfig

# Find implicit any left after enabling strict incrementally
npx tsc --noEmit --noImplicitAny 2>&1 | grep -c 'implicitly has an'

# Generate types from a JSON sample
npx quicktype --lang ts --src sample.json --just-types

# Unused exports across the project
npx ts-prune | grep -v '(used in module)'

# Circular imports
npx madge --circular --extensions ts src

# Dependency versions that ship their own types
node -e "const p=require('./package.json');console.log(Object.keys({...p.dependencies}).filter(d=>d.startsWith('@types/')))"

# Run a TypeScript file directly
node --experimental-strip-types src/script.ts

# Bundle size of the built output
npx esbuild src/index.ts --bundle --minify --platform=node | wc -c

Last updated 15 September 2026 · Edit this page