Languages
TypeScript
Type narrowing, generics and utility types, tsconfig settings that change what the compiler catches, and the runtime boundary.
Cheatsheet #
| Task | Command or syntax |
|---|---|
| Type-check only | tsc --noEmit |
| Watch | tsc --noEmit --watch |
| What type is this | hover, or type T = typeof x |
Narrow an unknown | typeof, instanceof, in, or a type predicate |
| Exhaustive switch | default: const _x: never = value |
| Freeze literal types | as const |
| Key of a type | keyof T |
| Value types of a type | T[keyof T] |
| Make optional | Partial<T> |
| Make required | Required<T> |
| Subset of keys | Pick<T, 'a' | 'b'> |
| Remove keys | Omit<T, 'a'> |
| Function’s return type | ReturnType<typeof fn> |
| Awaited value | Awaited<ReturnType<typeof fn>> |
| Runtime validation | zod, 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"]
}| Option | What it catches |
|---|---|
strict | Umbrella: null checks, implicit any, function variance, this |
noUncheckedIndexedAccess | arr[0] is T | undefined — the single highest-value extra flag |
exactOptionalPropertyTypes | Distinguishes “absent” from “set to undefined” |
isolatedModules | Ensures each file transpiles alone, required by esbuild/swc |
verbatimModuleSyntax | Makes import type explicit, avoiding phantom runtime imports |
skipLibCheck | Skips 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