TypeScript · cheat sheet

TypeScript

Structural typing, narrowing, generics, mapped and conditional types, enums vs unions, satisfies, classes, declaration files, tsconfig and the classic traps.

The TypeScript type system on one page, from unknown to infer. Every snippet compiles with tsc --strict (checked against TypeScript 7.0).

Why TypeScript

  • TypeScript is JavaScript plus static types. Types are erased at compile time, so nothing is checked at runtime: validate external data (JSON, forms, env vars) yourself.
  • Type checking and emit are separate: tsc still emits JavaScript when there are type errors unless noEmitOnError is set. Bundlers such as esbuild and SWC strip types without checking them.
  • Structural typing: compatibility is decided by shape, not by name. Anything that has the required members fits.
  • interface and type exist only for the checker; class and enum create both a type and a runtime value.
  • Branded types simulate nominal typing when two strings must not mix (user IDs vs order IDs).
interface Point { x: number; y: number }
class Pixel { constructor(public x: number, public y: number, public color = 'red') {} }
const p: Point = new Pixel(1, 2);  // ok: Pixel has x and y, extra members are fine

type UserId = string & { readonly __brand: 'UserId' };
const toUserId = (s: string) => s as UserId;
function loadUser(id: UserId) {}
loadUser('42');                    // error: string is not a UserId
loadUser(toUserId('42'));          // ok
TypeScript

Core & special types

Type Holds Use it for
string, number, boolean, bigint, symbol primitives (lowercase; String is the wrapper object) everyday values
null, undefined only themselves, under strictNullChecks “no value”: T | null or x?: T
any anything; switches checking off and spreads migration, last resort
unknown anything, but must be narrowed before use untrusted input, catch (e)
never no value at all (bottom type, assignable to everything) exhaustive checks, functions that always throw
void “no useful return value” function return types
object any non-primitive “some object”
{} any value except null and undefined, primitives included rarely what you mean
  • any vs unknown: both accept every value. With any you can do anything (x.foo.bar() compiles); with unknown you can do nothing until you narrow. unknown is the top type, never the bottom.
  • Without strictNullChecks, null and undefined are assignable to every type, which is the main reason to keep strict on.
  • void vs undefined: a function type () => void accepts functions that return a value (the result is ignored), so items.forEach((x) => list.push(x)) is fine.
  • never also shows up when narrowing removes every option and for impossible intersections: string & number is never.
  • Inference: let s = 'hi' is string, const s = 'hi' is the literal type 'hi'. Annotate parameters and exported APIs; let locals infer.

Type vs interface, unions & literals

interface type alias
Object shapes yes yes
Unions, tuples, primitives, mapped and conditional types no yes
Extending extends: conflicting members are an error &: conflicting members become never
Declaration merging yes: reopen it to add members no: a duplicate name is an error
A class can implement it yes yes, if it is an object type (not a union)
Assignable to Record<string, unknown> no (no implicit index signature) yes
  • Rule of thumb: interface for object shapes and extendable public APIs, type for unions, tuples, function types and computed types. Consistency matters more than the choice (type vs interface).
type Status = 'idle' | 'loading' | 'error';     // union of literal types
type Timestamped = { createdAt: Date };
type Post = { title: string } & Timestamped;    // intersection: has both

const ROLES = ['admin', 'editor'] as const;      // readonly ['admin', 'editor']
type Role = (typeof ROLES)[number];              // 'admin' | 'editor'

const req = { method: 'GET' };                   // { method: string }: widened
const req2 = { method: 'GET' } as const;         // { readonly method: 'GET' }
TypeScript
  • A | B: the value is one of them, so only members common to all are usable until you narrow. A & B: the value is both, so it has every member.
  • as const makes literals deeply readonly and stops widening: the standard way to derive a union from an array or object.

Narrowing

Technique Example Narrows to
typeof typeof x === 'string' primitives; 'object' still includes null
instanceof e instanceof Error class instances (prototype chain)
in 'swim' in pet union members that declare the key
Equality, truthiness x !== null, x != null, if (x) drops null/undefined (truthiness also drops 0, '')
Discriminated union switch (s.kind) the member with that literal tag
Exhaustive check const _: never = s compile error when a case is missing
Type predicate (x): x is Fish => ... your own guard, usable in if and filter
Assertion function asserts x is T, asserts cond narrowed after the call (or it throws)
type Shape = { kind: 'circle'; r: number } | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.r ** 2;   // s: { kind: 'circle'; r: number }
    case 'rect': return s.w * s.h;
    default: {
      const unreachable: never = s;             // error here if a new kind is added
      return unreachable;
    }
  }
}
TypeScript
const isString = (x: unknown): x is string => typeof x === 'string';

function assert(cond: unknown, msg = 'Assertion failed'): asserts cond {
  if (!cond) throw new Error(msg);
}
function shout(name: string | null) {
  assert(name !== null, 'name required');
  return name.toUpperCase();                    // name: string
}
const ids = ['a', undefined, 'b'].filter((x) => x !== undefined); // string[]
TypeScript
  • Predicates are trusted, not verified: a wrong x is T lies to the compiler.
  • Since TypeScript 5.5, simple predicates like the filter callback above are inferred; before that, ids was (string | undefined)[].
  • An arrow-function assertion needs an explicit type on the variable, or calling it fails with “Assertions require every name in the call target to be declared with an explicit type annotation”.
  • A narrowed let or parameter stays narrowed inside a callback only if it is not reassigned later (TS 5.4+). Otherwise copy it into a const first.

Generics

function first<T>(items: readonly T[]): T | undefined { return items[0]; }

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
const nm = getProp({ id: 1, name: 'Ada' }, 'name'); // string
getProp({ id: 1 }, 'email');                        // error: '"email"' is not '"id"'

interface ApiResponse<T = unknown> { data: T; error?: string } // default type argument

function tuple<const T extends readonly unknown[]>(...xs: T): T { return xs; }
const t = tuple('a', 1);                            // readonly ['a', 1]
TypeScript
  • Type parameters relate inputs to outputs. If T appears only once in a signature, you don’t need it: use unknown or the constraint.
  • <T extends X> is a constraint (T must be assignable to X), not inheritance.
  • Inference reads T from the arguments; pass it explicitly (useState<User | null>(null)) when the argument is not enough. It is all or nothing: you can’t pass some type arguments and infer the rest, except through defaults.
  • const type parameters (TS 5.0) infer literal types as if the caller wrote as const. NoInfer<T> (TS 5.4) stops one argument from driving inference.
  • More practice: generics interview questions.

keyof, typeof & mapped types

const config = { host: 'localhost', port: 5432 };
type Config = typeof config;         // { host: string; port: number }
type ConfigKey = keyof Config;       // 'host' | 'port'
type Port = Config['port'];          // number (indexed access)

type Optional<T> = { [K in keyof T]?: T[K] };                       // like Partial
type Mutable<T> = { -readonly [K in keyof T]: T[K] };               // strips readonly
type Setters<T> = { [K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => void };
type StringKeys<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };
type S = StringKeys<Config>;         // { host: string }
TypeScript
  • typeof in a type position reads a value’s type; keyof is the union of keys; T[K] looks up a property type; T[number] is an array or tuple’s element type.
  • keyof a type with a string index signature is string | number, because JavaScript turns numeric keys into strings.
  • Modifiers: readonly and ? add, -readonly and -? remove (+ is implied).
  • Key remapping with as (TS 4.1) renames keys; mapping a key to never drops it.
  • Mapped types over keyof T are homomorphic: they keep readonly and ?, and on tuples they produce tuples (Optional<[string, number]> is [string?, number?]).

Conditional & template types

type IsString<T> = T extends string ? true : false;
type A = IsString<'a' | 1>;              // boolean: distributes to true | false
type IsStringStrict<T> = [T] extends [string] ? true : false;
type B = IsStringStrict<'a' | 1>;        // false: [T] turns distribution off

type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;

type Params<P> = P extends `${string}:${infer Name}/${infer Rest}`
  ? Name | Params<Rest>
  : P extends `${string}:${infer Name}` ? Name : never;
type R = Params<'/users/:id/posts/:postId'>; // 'id' | 'postId'
TypeScript
  • T extends U ? X : Y distributes over a union when T is a naked type parameter: it runs per member and unions the results.
  • never is the empty union, so a distributive conditional on never gives never: IsString<never> is never, not false.
  • infer X captures part of a type inside the extends clause; infer X extends string adds a constraint (TS 4.7).
  • Conditional types can recurse (DeepReadonly, the Params parser above); very deep recursion hits a compiler limit.
  • Template literal types build string types. A union in a slot yields the cross product: `${'a' | 'b'}-${'x' | 'y'}` has four members. Uppercase, Lowercase, Capitalize and Uncapitalize transform them.

Enums, const objects & satisfies

enum Direction { Up, Down }                   // numeric: Up = 0, Down = 1, reverse-mapped
enum Level { Info = 'INFO', Warn = 'WARN' }   // string: no reverse mapping
const up = Direction[0];                      // 'Up'
const lvl: Level = 'INFO';                    // error: use Level.Info

const Color = { Red: 'RED', Green: 'GREEN' } as const;
type Color = (typeof Color)[keyof typeof Color]; // 'RED' | 'GREEN'
function paint(c: Color) {}
paint('RED');                                 // ok: plain strings work
paint(Color.Green);                           // ok
TypeScript
enum union of literals as const object
Runtime value yes, an object no (erased) yes, a plain object
Accepts the raw value numeric: any number variable; string: no yes yes
List members at runtime Object.values (numeric ones include reverse keys) no Object.values(Color)
Erasable syntax no yes yes
  • Erasable matters because Node’s built-in type stripping and the erasableSyntaxOnly flag (TS 5.8) reject syntax that emits code: enum, namespaces with values, parameter properties.
  • const enum members are inlined and no object is emitted, but tools that transpile one file at a time (isolatedModules) can’t inline enums declared in other files. Avoid them in libraries.
type Theme = Record<'primary' | 'accent', string | [number, number, number]>;
const theme = { primary: '#0af', accent: [255, 0, 128] } satisfies Theme;
theme.primary.toUpperCase();     // ok: still string

const annotated: Theme = { primary: '#0af', accent: [255, 0, 128] };
annotated.primary.toUpperCase(); // error: string | [number, number, number]
TypeScript
  • x: T checks and widens to T; x satisfies T (TS 4.9) checks but keeps the inferred type; x as T asserts with only a loose overlap check. as const satisfies T gives validated literal types.

Functions & classes

type Formatter = (value: number, locale?: string) => string;  // function type
interface Counter { (): number; reset(): void }               // callable with props
type Ctor<T> = new (...args: any[]) => T;                     // construct signature

function parse(input: string): number;                        // overload signatures
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] { // implementation
  return Array.isArray(input) ? input.map(Number) : Number(input);
}
const one = parse('42');         // number
const many = parse(['1', '2']);  // number[]
TypeScript
  • Optional x?: T, default x = 1 (implies optional), rest ...xs: T[]. Optional parameters come after required ones.
  • Callers see only the overload signatures, never the implementation signature; list the most specific first. Prefer a union or generic when the return type doesn’t depend on the input.
  • A this parameter (function (this: HTMLElement, e: Event)) only types this and is erased.
interface Repo { find(id: string): Promise<string | undefined> }

abstract class Service {
  abstract readonly name: string;
  protected log(msg: string) { console.log(`[${this.name}] ${msg}`); }
}
class UserService extends Service implements Repo {
  readonly name = 'users';
  #cache = new Map<string, string>();                          // runtime-private
  constructor(private readonly baseUrl: string) { super(); }  // parameter property
  async find(id: string) { this.log(id); return this.#cache.get(id); }
}
TypeScript
private (TypeScript) #private (JavaScript)
Enforced compile time only; obj['x'] still compiles at runtime by the engine
Seen by JSON.stringify, Object.keys yes no
Subclass reuses the name error allowed, a separate field
  • public is the default, protected allows subclasses, readonly blocks reassignment after construction.
  • abstract classes can’t be instantiated; concrete subclasses must implement every abstract member.
  • implements only checks the class. It adds no members and doesn’t type method parameters: find(id) would still be an implicit any.
  • override marks overridden members; noImplicitOverride makes it mandatory. Under strictPropertyInitialization, fields need an initializer, a constructor assignment, or ! (el!: HTMLElement).
  • Decorators: TS 5.0 supports standard (TC39) decorators; experimentalDecorators switches to the older legacy version that some frameworks were built on.

Tuples, records & utility types

type Entry = [key: string, value: number];         // labeled tuple
type Point3 = [x: number, y: number, z?: number];  // optional element
type Args = [string, ...number[]];                 // rest element
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B]; // variadic
type AB = Concat<[1, 2], ['x']>;                   // [1, 2, 'x']

function total(xs: readonly number[]) { return xs.reduce((a, b) => a + b, 0); }
const scores: Record<string, number> = { ada: 3 };
interface HttpHeaders { [name: string]: string; 'content-type': string }
type DataAttrs = { [key: `data-${string}`]: string };  // pattern index signature
TypeScript
  • readonly T[] (same as ReadonlyArray<T>) has no push or sort. A mutable array is assignable to a readonly one, not the reverse, so accept readonly parameters.
  • Without as const or an annotation, const pair = ['a', 1] is (string | number)[], not a tuple. Hooks that return tuples need as const.
  • With an index signature, every declared property must fit its value type, and obj[key] reads as V, not V | undefined, unless noUncheckedIndexedAccess is on.
  • Record<'a' | 'b', V> requires every key. Use Partial<Record<K, V>> for sparse objects and Map for dynamic keys with frequent adds and deletes.
Utility group Types (full sheet)
Object modifiers Partial, Required, Readonly
Keys Pick, Omit (keys unchecked), Record
Union filters Exclude, Extract, NonNullable
Functions & classes Parameters, ReturnType, ConstructorParameters, InstanceType, ThisParameterType, OmitThisParameter, ThisType
Promises & inference Awaited, NoInfer
Strings Uppercase, Lowercase, Capitalize, Uncapitalize

Declarations & modules

// globals.d.ts: no top-level import/export, so everything here is global
declare module '*.svg' { const url: string; export default url; }
declare module 'untyped-lib';           // shorthand: every import is `any`
declare const __APP_VERSION__: string;  // injected by the bundler
TypeScript
// augment.d.ts: it has an export, so `declare module` augments instead of replacing
export {};
declare module 'express-serve-static-core' {
  interface Request { user?: { id: string; roles: string[] } }
}
declare global {
  interface Window { analytics?: { track(event: string): void } }
}
TypeScript
  • .d.ts files contain only types. declare describes something that exists at runtime but wasn’t written in TypeScript.
  • Types ship inside the package (types in its package.json) or come from DefinitelyTyped: npm i -D @types/lodash.
  • Augmentation relies on interface merging. It only works in a module file; in a script file, declare module 'x' declares a new ambient module that replaces the real types.
  • import type and export type are always erased. verbatimModuleSyntax requires them for type-only imports, so the emitted imports are exactly what you wrote.
  • /// <reference types="vite/client" /> pulls in a package’s global types.

tsconfig essentials

Option Effect Typical
strict the strict family: strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables and more true
noUncheckedIndexedAccess arr[i] and rec[k] include | undefined; not part of strict true for new code
exactOptionalPropertyTypes x?: T rejects an explicit undefined optional
target syntax level of the emitted JS; also picks the default lib es2022 or later
lib which built-in APIs exist (dom, es2023, …) match the runtime
module module format of the output nodenext for Node, esnext or preserve with a bundler
moduleResolution how import paths resolve nodenext or bundler
esModuleInterop import x from 'cjs-lib' works; implies allowSyntheticDefaultImports true
isolatedModules, verbatimModuleSyntax every file transpilable on its own (esbuild, SWC, Babel) true with bundlers
skipLibCheck skip type-checking .d.ts files true, for speed
noEmit type-check only; the bundler emits apps
declaration, outDir, rootDir, sourceMap output libraries
paths import aliases like @/*; the bundler must agree as needed
noImplicitOverride, noImplicitReturns, noFallthroughCasesInSwitch extra safety true

Note

Defaults changed. The TypeScript 7.0 compiler this sheet was checked with defaults strict and esModuleInterop to true, and rejects target: es5, moduleResolution: node/node10/classic, baseUrl, outFile and AMD/UMD modules. TypeScript 5.x defaulted strict to false, so always set it explicitly.

Typing React & libraries

import type { ChangeEvent, ComponentPropsWithoutRef, MouseEvent, ReactNode } from 'react';

type ButtonProps = ComponentPropsWithoutRef<'button'> & {
  variant?: 'primary' | 'ghost';
  icon?: ReactNode;                                   // anything renderable
};
type LinkOrButton =                                   // props as a discriminated union
  | { as: 'a'; href: string }
  | { as: 'button'; onClick: (e: MouseEvent<HTMLButtonElement>) => void };
const onChange = (e: ChangeEvent<HTMLInputElement>) => console.log(e.target.value);
type ListProps<T> = { items: T[]; render: (item: T) => ReactNode }; // generic component
TypeScript
  • Type props on the parameter: function Button({ variant = 'primary', ...rest }: ButtonProps). children is ReactNode. React.FC is optional and, since the React 18 types, doesn’t add children for you.
  • Hooks: useState<User | null>(null); useRef<HTMLInputElement>(null) gives RefObject<HTMLInputElement | null>; custom hooks that return tuples need as const.
  • Events: ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>, KeyboardEvent. Recent @types/react versions deprecate FormEvent; onSubmit handlers take SubmitEvent<HTMLFormElement>.
  • Reuse props: ComponentPropsWithoutRef<'input'> for native elements, ComponentProps<typeof Button> for components.
  • Libraries: derive types from runtime schemas (type User = z.infer<typeof UserSchema> in Zod); res.json() returns Promise<any>, so validate it; Express handlers are generic, as in Request<Params, ResBody, ReqBody>.
type Events = { login: { userId: string }; logout: undefined };
declare function on<K extends keyof Events>(type: K, fn: (payload: Events[K]) => void): void;
on('login', (p) => console.log(p.userId));  // p: { userId: string }
on('signup', () => {});                     // error: not a key of Events
TypeScript

Best practices & debugging

  • Turn on strict (and noUncheckedIndexedAccess) on day one; migrating later is far more painful. Lint against any (@typescript-eslint/no-explicit-any).
  • Validate at the boundaries (unknown plus a schema), then trust the types inside.
  • Model state as discriminated unions ({ status: 'loading' } | { status: 'ok'; data: T }) instead of optional flags that allow impossible combinations.
  • Keep one source of truth: derive types with typeof, keyof, ReturnType or z.infer instead of copying them.
  • Prefer unknown to any, satisfies to as, literal unions to enums, and readonly inputs.
  • Use // @ts-expect-error rather than // @ts-ignore: it fails once the error is gone.
  • Debugging: hover in the editor to see inferred types; run tsc --noEmit in CI; --traceResolution explains “Cannot find module”; --explainFiles shows why a file is included; --noErrorTruncation prints full types.

Quick answers

  • any vs unknown? Both accept everything; unknown forces a check before use, any turns checking off.
  • type vs interface? Both describe objects; interfaces merge and extend, types also cover unions, tuples and computed types.
  • What is never? The type with no values: impossible branches, exhaustive checks, functions that never return.
  • Structural or nominal? Structural: same shape means compatible. Brands fake nominal types.
  • What does as const do? Infers the narrowest literal types and makes the value deeply readonly.
  • satisfies vs an annotation? Both check; satisfies keeps the narrower inferred type.
  • Is as a cast? No. It changes only the compile-time type; nothing converts at runtime.
  • Discriminated union? Members share a literal tag (kind); checking the tag narrows to one member.
  • Generic constraint? T extends X limits T to types assignable to X, so X’s members are usable.
  • What does infer do? Captures a type inside a conditional type, e.g. a promise’s value type.
  • Why is Object.keys string[]? Structural typing lets an object carry more keys than its type lists.
  • Enum or union? Usually a literal union or an as const object: no runtime cost, erasable, plain strings work.
  • What does strict do? Enables the strict family, most importantly strictNullChecks and noImplicitAny.
  • What is a .d.ts file? Type declarations for JavaScript code, shipped with the package or via @types/*.
  • Do types exist at runtime? No, they’re erased, so validate untrusted data at runtime.

Gotchas & traps

  • Excess property checks apply only to fresh object literals: const o: Opts = { name: 'a', nmae: 'b' } errors, but assigning a variable with the same extra key compiles.
  • Object.keys(obj) is string[], not (keyof T)[] (likewise for...in and Object.entries), because the object may have extra keys. Assert as (keyof T)[] only when you’re sure.
  • Type assertions aren’t casts: '5' as unknown as number is still a string at runtime. JSON.parse returns any, which spreads silently.
  • Method parameters are bivariant: interface H { handle(a: Animal): void } accepts { handle(d: Dog) {} }. The property form handle: (a: Animal) => void is checked strictly under strictFunctionTypes.
  • Enum pitfalls: numeric enums accept any number variable and are reverse-mapped, so Object.keys(Direction) is ['0', '1', 'Up', 'Down']; string enums reject the equal plain string; const enum breaks file-by-file transpilers.
  • implements doesn’t type parameters, and typeof x === 'object' leaves null in the type.
  • Array.isArray narrows a readonly string[] union member to any[].
  • {} isn’t “empty object”: it accepts 0 and 'x'. Use Record<string, never> for an empty object, object for any non-primitive.
  • x?: T vs x: T | undefined: the first may be missing, the second must be present (even if undefined).
  • readonly and Readonly<T> are shallow and compile-time only; Object.freeze is the runtime version.
esc