Chapter 3

The TypeScript type system

Structural typing, generics, conditional types, mapped types, and utility types.

The TypeScript type system

TypeScript interviews split into two halves. The first is “do you know the syntax” — Partial, generics, keyof. The second is the one that separates people: can you reason about the type system as a language, predict what the compiler will infer, and explain why a particular error is correct. This file is aimed at the second half.

Every type-level claim below was compiled and checked. The mechanism is a compile-time assertion:

type Equals<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Assert<T extends true> = T;

type _check = Assert<Equals<Split<'a.b.c', '.'>, ['a', 'b', 'c']>>;   // fails to compile if wrong

Equals works by comparing two deferred conditional types — the only construct that makes the compiler compare X and Y for identity rather than mutual assignability, which is why it distinguishes any from unknown and {a: 1} from {a: 1} & {}. The 25 utility assertions and the 8 semantic assertions in this file all compile with zero errors under tsc --strict --target es2022 --lib es2023. Where a claim needed an error to prove the point, it is written with @ts-expect-error, which itself fails to compile if the error does not occur — so the negative claims are verified too.

Table of contents


Start-here note. This chapter goes deep on type-level programming but skips several load-bearing mental models: types as sets of values, the type-space/value-space divide, excess property checking, contextual typing, and widening. Those are closed in 21 §4 and 21 §5. If type errors still sometimes read as arbitrary, read those first.

1. Mental model

Three sentences that organize everything else:

  1. TypeScript is structural. A value is assignable to a type if its shape is compatible. Names are documentation, not identity. (Java and C# are nominal: the name is the identity.)
  2. Types are erased. There is no runtime representation. Anything you want to check at runtime you must check with JavaScript — typeof, in, instanceof, a discriminant field, or a schema library.
  3. The checker is a proof assistant with an unsound escape hatch. any and as are the escape hatches; every unsoundness in a well-typed program traces back to one of them, or to one of the four deliberate unsoundnesses the language ships (array covariance, method bivariance, optional-property assignability, and object index signatures).

1.1 Structural typing in practice

interface Point { x: number; y: number }
function dist(p: Point) { return Math.hypot(p.x, p.y); }

class Vec2 { constructor(public x: number, public y: number) {} }
dist(new Vec2(3, 4));          // fine: same shape, no `implements` needed
dist({ x: 3, y: 4 });          // fine
const v = { x: 3, y: 4, z: 5 };
dist(v);                       // fine: extra properties allowed for a variable
dist({ x: 3, y: 4, z: 5 });    // ERROR: excess property check on a fresh object literal

That last pair is the “excess property check”: object literals passed directly are checked strictly, because a stray property is almost always a typo. Assign it to a variable first and the check goes away — the literal freshness is lost. This asymmetry surprises people; being able to explain it is a good signal.

1.2 Faking nominal types

You want nominal typing when two structurally identical types must not be interchangeable: UserId and PostId are both strings, and mixing them is a real bug.

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

const asUserId = (s: string) => s as UserId;      // the one sanctioned assertion, in one place
function getUser(id: UserId) { /* ... */ }

getUser(asUserId('u_1'));
// getUser('u_1');                 // ERROR: string is not UserId
// getUser('p_1' as PostId);       // ERROR: PostId is not UserId

The runtime value is still just a string — zero cost. Use this for IDs, units (Meters, Seconds), and validated values (Email, SanitizedHtml, NonEmptyArray). The rule is: one factory function per brand, and that factory is the only place the assertion lives.


2. The type lattice

                        any            (top AND bottom — assignable both ways, unsound)
                         |
                      unknown          (true top: everything is assignable TO it, nothing FROM it)
             ┌───────────┼─────────────────────────────┐
          object      primitives                   null | undefined
             |      (string, number,                   (only under
        {} interfaces  boolean, bigint,             strictNullChecks)
        classes,       symbol, void*)
        arrays,
        functions
             └───────────┬─────────────────────────────┘
                       never           (bottom: assignable to everything, nothing assignable to it)
TypeAssignable to itAssignable from itUse it for
anyeverythingeverythingnever, if you can help it
unknowneverythingnothing without narrowingthe return of JSON.parse, catch bindings, boundaries
nevernothingeverythingimpossible states, exhaustiveness, functions that never return
voidundefined (and null without strictNullChecks)assignable to nothing useful“I do not care about the return value”
undefinedundefinedundefined, void, any, unknownabsence of a value
objectany non-primitivenon-primitives“not a primitive”
{}anything except null/undefinedalmost nothing; it is not “empty object”
Objectalmost everything (has toString etc.)never
Record<string, unknown>objects with string keysthe actual “some object” type you wanted

Three distinctions worth getting exactly right:

unknown vs any. Both accept anything. unknown refuses to let you use the value until you narrow it; any disables checking downstream and the errors surface somewhere else. unknown is strictly better in every case except migrating legacy code.

void vs undefined. A function typed () => void may return anything — the caller has promised to ignore it. This is what makes arr.forEach(x => set.add(x)) legal even though add returns the set. A function typed () => undefined must actually return undefined.

never is not void. never means “this never produces a value”: a function that always throws, the type of an impossible union member, the element type of []. never in a union vanishes (string | never is string), which is the mechanism behind filtered mapped types.

2.1 The strictness flags that change semantics

FlagEffectWorth turning on?
strictNullChecksnull/undefined are no longer members of every typenon-negotiable
strictFunctionTypesfunction-type parameters become contravariant (methods stay bivariant)yes
strictPropertyInitializationclass fields must be assigned in the constructoryes
strictBindCallApplybind/call/apply are type-checkedyes
noImplicitAnyuntyped parameters erroryes
noImplicitThisthis of unknown type errorsyes
useUnknownInCatchVariablescatch (e) is unknown instead of anyyes
noUncheckedIndexedAccessarr[i] is T | undefinedyes, and it is the one that finds real bugs
exactOptionalPropertyTypes{a?: string} no longer accepts {a: undefined}yes, if your code can take it
noImplicitOverrideoverride keyword requiredyes
noPropertyAccessFromIndexSignatureindex-signature members need bracket accesstaste

noUncheckedIndexedAccess is the interview-worthy one, because it exposes a genuine hole:

const arr = [1, 2, 3];
const x = arr[10];             // without the flag: number.  With it: number | undefined.
// x.toFixed()                 // the flag turns a runtime crash into a compile error

3. Narrowing and control flow analysis

3.1 The narrowing operators

graph TD
    A["x: string | number | Date | null | undefined | string[]"]
    B{"typeof x === 'string'"}
    C["narrowed: string"]
    D{"typeof x === 'number'"}
    E["narrowed: number"]
    F{"x instanceof Date"}
    G["narrowed: Date"]
    H{"Array.isArray(x)"}
    I["narrowed: string[]"]
    J{"x == null"}
    K["narrowed: null | undefined"]
    L["narrowed: never"]

    A --> B
    B -- true --> C
    B -- false --> D
    D -- true --> E
    D -- false --> F
    F -- true --> G
    F -- false --> H
    H -- true --> I
    H -- false --> J
    J -- true --> K
    J -- false --> L
function f(x: string | number | Date | null | undefined | string[]) {
  if (typeof x === 'string') x;                 // string
  else if (typeof x === 'number') x;            // number
  else if (x instanceof Date) x;                // Date
  else if (Array.isArray(x)) x;                 // string[]
  else if (x == null) x;                        // null | undefined  (== catches both)
  else x;                                       // never
}

// `in` narrows on property presence
type A = { kind: 'a'; a: number }; type B = { kind: 'b'; b: string };
const g = (v: A | B) => ('a' in v ? v.a : v.b);

// truthiness narrowing removes null/undefined/''/0/NaN/false
const h = (s?: string) => (s ? s.length : 0);

// equality narrowing propagates between two union-typed values
function eq(x: string | number, y: string | boolean) {
  if (x === y) { x; y; }                        // both narrowed to string
}

The trap: truthiness narrowing removes '' and 0, which are often valid values. Use x != null or x !== undefined when you mean “present”, not if (x).

3.2 Discriminated unions and exhaustiveness

This is the single most valuable pattern in the language. A common literal-typed field lets the compiler select the member.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    case 'rect':   return s.w * s.h;
    default: {
      const _exhaustive: never = s;      // add a member to Shape -> this line errors
      throw new Error(`unhandled ${JSON.stringify(_exhaustive)}`);
    }
  }
}

The never assignment is the exhaustiveness check, and it is the thing to reach for whenever an interviewer asks “how do you make sure you handle every case”. Standard-library helper: function assertNever(x: never): never { throw new Error('unexpected: ' + x); }.

Discriminants must be literal types. kind: string narrows nothing.

3.3 Type guards and assertion functions

// user-defined type guard: the return type is a predicate
function isString(x: unknown): x is string { return typeof x === 'string'; }

// guards compose into filters — and this is the idiomatic way to drop nulls
function isDefined<T>(x: T | null | undefined): x is T { return x != null; }
const clean: string[] = ['a', null, 'b'].filter(isDefined);

// assertion function: narrows for the REST of the enclosing scope
function assertIsUser(x: unknown): asserts x is { id: string } {
  if (typeof x !== 'object' || x === null || !('id' in x)) throw new TypeError('not a user');
}
function use(raw: unknown) { assertIsUser(raw); raw.id; }   // narrowed from here on

// `asserts this is T` for stateful builders
class Conn {
  #sock?: WebSocket;
  assertOpen(): asserts this is { readonly sock: WebSocket } {
    if (!this.#sock) throw new Error('closed');
  }
}

Assertion functions must be declared with an explicit type annotation at the call site (a const holding an arrow function needs : (x: unknown) => asserts x is T) — an ergonomic wart worth knowing.

The honesty caveat. A type guard is an unchecked promise. function isUser(x: unknown): x is User { return true } compiles. At real trust boundaries use a validator that derives the type from the schema (zod, valibot, typia, arktype) so the type and the check cannot drift.

3.4 satisfies vs annotation vs assertion

The most useful TypeScript feature of the last few years, and a very common interview question. Verified:

const withAnnotation: Record<string, number | string> = { x: 1, y: 'a' };
const withSatisfies  = { x: 1, y: 'a' } satisfies Record<string, number | string>;

type T1 = typeof withAnnotation['x'];   // string | number   <- widened to the annotation
type T2 = typeof withSatisfies['x'];    // number            <- inference preserved
FormChecks the valueKeeps the narrow inferred typeCan lie
const x: T = v (annotation)yesno — x is Tno
const x = v satisfies Tyesyesno
const x = v as T (assertion)no (only bidirectional-ish)replaces the typeyes

Use satisfies when you want the constraint checked but the literal types kept — config objects, route tables, theme maps, as const records you will index into. Use an annotation when you want the value to be the wider type. Use as only when you know something the compiler cannot, and prefer a type guard or a branded factory instead.

as const is a third, orthogonal tool: it makes literals readonly and non-widening. satisfies and as const compose: {...} as const satisfies Config.

3.5 Where control flow analysis gives up

type Box = { v?: string };
function bad(b: Box) {
  if (b.v) {
    [1].forEach(() => b.v.length);   // ERROR: b.v is possibly undefined
  }
}

Narrowing on a property is discarded inside a callback, because the compiler cannot prove the object was not mutated in between. Fix by hoisting to a const: const v = b.v; if (v) [1].forEach(() => v.length). Other CFA limits: narrowing is lost across await for mutable bindings, aliased conditions only work for const booleans (TS 4.4+), and generic type parameters do not narrow — T extends string does not make a T usable as a string after a typeof check without a cast or an overload.


4. Generics

4.1 Constraints, defaults, and inference sites

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

// constraint: T must have a length
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; }

// default
interface Paged<T, M = { total: number }> { items: T[]; meta: M }

// keyof constraint — the canonical typed property accessor
function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
const name = get({ id: 1, name: 'ana' }, 'name');   // string, and 'nam' is a compile error

// multiple keys
function pluck<T, K extends keyof T>(objs: T[], key: K): T[K][] { return objs.map(o => o[key]); }

Inference flows from the arguments, and later parameters can widen an earlier inference:

declare function pick<T>(items: T[], fallback: T): T;
pick(['a', 'b'], 42);        // T infers as string | number — probably not what you wanted

declare function pick2<T>(items: T[], fallback: NoInfer<T>): T;   // TS 5.4+
// pick2(['a','b'], 42);     // ERROR: 42 is not assignable to string

NoInfer<T> (TS 5.4) marks a position as “check here, do not infer here”. Before it existed the trick was a second type parameter constrained to the first.

4.2 const type parameters

function tuple<const T extends readonly unknown[]>(...xs: T): T { return xs; }
const t = tuple('a', 1, true);       // readonly ["a", 1, true]  — no `as const` at the call site

Without const, T would infer as (string | number | boolean)[]. This is how modern libraries get literal inference without making every caller write as const.

4.3 Higher-order function inference

// generic signature is preserved through the wrapper
function withLogging<A extends unknown[], R>(fn: (...a: A) => R): (...a: A) => R {
  return (...a) => { console.time('fn'); try { return fn(...a); } finally { console.timeEnd('fn'); } };
}

// ThisParameterType / OmitThisParameter for `this`-dependent callbacks
function bindTo<F extends (this: any, ...a: any[]) => any>(
  fn: F, self: ThisParameterType<F>
): OmitThisParameter<F> { return fn.bind(self); }

ParamSpec-style precision — preserving optionality, names and this — is exactly what Parameters<F> and the A extends unknown[] rest pattern buy you.

4.4 Generic classes and the this type

class QueryBuilder<T> {
  private parts: string[] = [];
  where(clause: string): this { this.parts.push(clause); return this; }   // polymorphic `this`
  build(): string { return this.parts.join(' AND '); }
}
class PgBuilder<T> extends QueryBuilder<T> {
  returning(cols: string): this { return this; }
}
new PgBuilder<{ id: number }>().where('id = 1').returning('*').where('x').build();

Returning this rather than QueryBuilder<T> is what keeps the subclass methods available through a fluent chain. This is the standard answer to “how do you type a builder pattern”.


5. Variance

Variance answers: if Dog extends Animal, what is the relationship between F<Dog> and F<Animal>?

VarianceRuleWhere
CovariantF<Dog> assignable to F<Animal>outputs: return types, readonly properties, Promise<T>, arrays (unsoundly)
ContravariantF<Animal> assignable to F<Dog>inputs: function parameters under strictFunctionTypes
Bivariantboth directionsmethod parameters (deliberate unsoundness)
Invariantneithermutable properties in a nominal system; TS approximates

5.1 Method bivariance vs property contravariance

Verified with the compiler:

interface Animal { name: string }
interface Dog extends Animal { breed: string }

interface WithMethod { cmp(a: Dog): void }         // method shorthand -> bivariant parameter
interface WithProp   { cmp: (a: Dog) => void }     // property syntax  -> contravariant parameter

declare const wm: WithMethod;
const okBivariant: { cmp(a: Animal): void } = wm;                    // compiles
// @ts-expect-error property syntax is strictly contravariant
const notOk: { cmp: (a: Animal) => void } = ({} as WithProp);

Why the inconsistency? Because Array<T> needs bivariant methods to stay usable: arr.push(x) and arr.indexOf(x) would otherwise make Dog[] and Animal[] incompatible in directions the standard library depends on. The team chose unsoundness for methods and soundness for function-typed properties. Practical rule: declare callbacks with property syntax (onEvent: (e: E) => void) so you get the safe checking.

5.2 Array covariance is unsound, demonstrably

const dogs: Dog[] = [{ name: 'rex', breed: 'lab' }];
const animals: Animal[] = dogs;        // allowed: arrays are covariant
animals.push({ name: 'cat' });         // compiles
dogs[1].breed;                         // typed string, actually undefined at runtime

This compiles with zero errors under --strict. Say this out loud in an interview and you have demonstrated that you understand variance rather than memorized it. The mitigation is readonly T[] / ReadonlyArray<T>, which is genuinely covariant because there is no push.

5.3 Variance annotations

interface Producer<out T> { get(): T }        // covariant
interface Consumer<in T>  { set(v: T): void } // contravariant
interface Both<in out T>  { get(): T; set(v: T): void }   // invariant

in/out (TS 4.7) are mostly assertions — the compiler already infers variance structurally, and these let you document it and catch mistakes. Their real benefit is compile speed on large recursive generic types, because the checker can use the annotation instead of deriving it.


6. Conditional types

6.1 The basics

type IsArray<T> = T extends unknown[] ? true : false;
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;   // recursive

6.2 Distribution — the rule that explains most surprises

A conditional type with a naked type parameter on the left distributes over unions: Distr<A | B> becomes Distr<A> | Distr<B>.

type Distr<T>    = T extends string ? 'S' : 'N';
type NonDistr<T> = [T] extends [string] ? 'S' : 'N';

type _1 = Assert<Equals<Distr<string | number>, 'S' | 'N'>>;    // distributed
type _2 = Assert<Equals<NonDistr<string | number>, 'N'>>;       // NOT distributed

Wrapping both sides in a tuple ([T] extends [U]) is the standard way to switch distribution off. This is why Exclude<T, U> = T extends U ? never : T works: it distributes, tests each member, and never members vanish from the resulting union.

Two consequences people trip on:

type BadIsNever<T> = T extends never ? true : false;
type _3 = BadIsNever<never>;                     // never, not true! never is the empty union,
                                                 // so there is nothing to distribute over
type IsNever<T> = [T] extends [never] ? true : false;   // correct
type _4 = Assert<Equals<IsNever<never>, true>>;

type IsAny<T> = 0 extends (1 & T) ? true : false;       // the standard any-detector
type _5 = Assert<Equals<IsAny<any>, true>>;

IsAny works because 1 & any collapses to any, and 0 extends any is true, while 1 & T for any non-any T is a type 0 cannot extend.

6.3 infer

type Ret<F> = F extends (...a: any[]) => infer R ? R : never;
type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;
type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never;

// multiple infers in one position produce a union; in different positions, both are bound
type Both<T> = T extends { a: infer X; b: infer X } ? X : never;   // union of the two
type Pair<T> = T extends [infer A, infer B] ? { a: A; b: B } : never;

// infer with a constraint (TS 4.7+) — filters and narrows in one step
type FirstStringChar<S> = S extends `${infer C extends string}${string}` ? C : never;
type NumFromStr<S> = S extends `${infer N extends number}` ? N : never;
type _6 = NumFromStr<'42'>;    // 42

6.4 Recursion limits and tail elimination

The instantiation depth limit is 50 for non-tail-recursive conditional types and 1,000 for tail-recursive ones (TS 4.5+). A conditional type is tail-recursive when the recursive call is the entire body of a branch, with an accumulator threaded through:

// NOT tail-recursive: the recursive call is inside a tuple -> depth limit ~50
type ReverseSlow<T extends unknown[]> = T extends [infer H, ...infer R] ? [...ReverseSlow<R>, H] : [];

// tail-recursive with an accumulator -> depth limit ~1000
type ReverseFast<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer H, ...infer R] ? ReverseFast<R, [H, ...Acc]> : Acc;

When you hit “Type instantiation is excessively deep and possibly infinite”, the accumulator rewrite is the fix. If that is not enough, the problem does not belong in the type system.


7. Mapped types

7.1 Anatomy

type Mapped<T> = {
  readonly [K in keyof T as `get${Capitalize<K & string>}`]?: () => T[K];
//^^^^^^^^ modifier   ^^^^^^^^^ key remapping (as)          ^^^^^^^^^ value transform
};

Modifiers can be added or removed: readonly/-readonly, ?/-?.

type Mutable<T>  = { -readonly [K in keyof T]: T[K] };
type Required2<T> = { [K in keyof T]-?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };

7.2 Homomorphic mapped types

A mapped type of the exact form {[K in keyof T]: ...} is homomorphic: it preserves the input’s modifiers, and it distributes over arrays, tuples and unions instead of flattening them to objects.

type Partial2<T> = { [K in keyof T]?: T[K] };
type A = Partial2<[number, string]>;      // [number?, string?]  — still a tuple
type B = Partial2<number[]>;              // (number | undefined)[] — still an array
type C = Partial2<{ readonly a: 1 }>;     // { readonly a?: 1 } — readonly preserved

// NOT homomorphic (keyof T is written differently) -> becomes a plain object
type Partial3<T> = { [K in Extract<keyof T, string>]?: T[K] };

This is why Partial<SomeTuple> behaves sensibly and a hand-rolled variant may not. It also explains DeepReadonly’s array special case:

type DeepReadonly<T> =
  T extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>>
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;
type _7 = Assert<Equals<DeepReadonly<{ a: number[] }>, { readonly a: ReadonlyArray<number> }>>;

7.3 Key remapping and filtering

as never removes a key — the single most useful idiom in the whole feature.

type OmitByValue<T, V> = { [K in keyof T as T[K] extends V ? never : K]: T[K] };
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };
type _8 = Assert<Equals<PickByValue<{ a: string; b: number; c: string }, string>, { a: string; c: string }>>;

// getters/setters generated from a shape
type Accessors<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
} & {
  [K in keyof T as `set${Capitalize<K & string>}`]: (v: T[K]) => void;
};

// the OptionalKeys / RequiredKeys pair, which needs the -? trick
type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];
type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K }[keyof T];

The [keyof T] at the end is the “index the mapped type by its own keys to get a union of the values” trick — that is how you turn a mapped type into a union.


8. Template literal types

8.1 Pattern matching on strings

type Split<S extends string, D extends string> =
  S extends `${infer H}${D}${infer T}` ? [H, ...Split<T, D>] : [S];
type Join<T extends readonly string[], D extends string> =
  T extends readonly [] ? ''
  : T extends readonly [infer O extends string] ? O
  : T extends readonly [infer H extends string, ...infer R extends string[]] ? `${H}${D}${Join<R, D>}`
  : string;

type _9  = Assert<Equals<Split<'a.b.c', '.'>, ['a', 'b', 'c']>>;
type _10 = Assert<Equals<Join<['a', 'b', 'c'], '-'>, 'a-b-c'>>;

Intrinsic string types: Uppercase, Lowercase, Capitalize, Uncapitalize.

type CamelCase<S extends string> =
  S extends `${infer H}_${infer T}` ? `${H}${Capitalize<CamelCase<T>>}` : S;

type SnakeCase<S extends string, Acc extends string = ''> =
  S extends `${infer H}${infer T}`
    ? SnakeCase<T, `${Acc}${H extends Uppercase<H> ? (H extends Lowercase<H> ? H : `_${Lowercase<H>}`) : H}`>
    : Acc;

type _11 = Assert<Equals<CamelCase<'foo_bar_baz'>, 'fooBarBaz'>>;
type _12 = Assert<Equals<SnakeCase<'fooBarBaz'>, 'foo_bar_baz'>>;

The H extends Uppercase<H> ? (H extends Lowercase<H> ? ... ) double test is how you detect “is this character a letter with distinct cases” — digits and punctuation are equal under both.

8.2 Typed deep paths

The classic “type-safe lodash.get”:

type Paths<T, P extends string = ''> = T extends object
  ? { [K in keyof T & string]:
        P extends '' ? K | Paths<T[K], K> : `${P}.${K}` | Paths<T[K], `${P}.${K}`> }[keyof T & string]
  : never;

type PathValue<T, P extends string> =
  P extends `${infer H}.${infer R}` ? (H extends keyof T ? PathValue<T[H], R> : never)
  : P extends keyof T ? T[P] : never;

declare function get<T, P extends Paths<T>>(obj: T, path: P): PathValue<T, P>;

type Cfg = { db: { host: string; port: number }; debug: boolean };
type _13 = Assert<Equals<Paths<{ a: { b: string } }>, 'a' | 'a.b'>>;
type _14 = Assert<Equals<PathValue<{ a: { b: { c: string } } }, 'a.b.c'>, string>>;
// get({} as Cfg, 'db.port')   -> number
// get({} as Cfg, 'db.prot')   -> compile error

8.3 Typed parsers

type UnionToIntersection<U> =
  (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
type Prettify<T> = { [K in keyof T]: T[K] } & {};

type ParseQuery<S extends string> = Prettify<UnionToIntersection<
  Split<S, '&'>[number] extends infer Pair
    ? Pair extends `${infer K}=${infer V}` ? { [P in K]: V } : never
    : never>>;
type _15 = Assert<Equals<ParseQuery<'x=1&y=2'>, { x: '1'; y: '2' }>>;

type RouteParams<S extends string> =
  S extends `${string}:${infer P}/${infer R}` ? { [K in P]: string } & RouteParams<`/${R}`>
  : S extends `${string}:${infer P}` ? { [K in P]: string } : {};
type _16 = Assert<Equals<RouteParams<'/users/:id/posts/:postId'>, { id: string } & { postId: string }>>;

UnionToIntersection deserves its own explanation because it comes up constantly: it puts U in a contravariant position (a function parameter), and TypeScript’s rule for assigning a union of function types to a single function type is to intersect the parameters. That is the whole trick.


9. Tuples and variadic types

// labeled tuple elements — names show up in editor hints and error messages
type Point3 = [x: number, y: number, z: number];

// rest in any position (TS 4.0+)
type Args = [first: string, ...middle: number[], last: boolean];

// spreading generics
type Prepend<T extends unknown[], V> = [V, ...T];
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B];

type _17 = Assert<Equals<Reverse<[1, 2, 3]>, [3, 2, 1]>>;
type Reverse<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer H, ...infer R] ? Reverse<R, [H, ...Acc]> : Acc;

9.1 Typed pipe and curry

// pipe: each function's input must be the previous function's output
type PipeArgs<F extends unknown[], Acc extends unknown[] = []> =
  F extends [(...a: infer A) => infer B]
    ? [...Acc, (...a: A) => B]
    : F extends [(...a: infer A) => infer B, ...infer Rest]
      ? Rest extends [(a: infer C) => unknown, ...unknown[]]
        ? B extends C ? PipeArgs<Rest, [...Acc, (...a: A) => B]> : never
        : Acc
      : Acc;

declare function pipe<F extends [(...a: any[]) => any, ...Array<(a: any) => any>]>(
  ...fns: PipeArgs<F> extends F ? F : PipeArgs<F>
): (...a: Parameters<F[0]>) => F extends [...unknown[], (...a: any[]) => infer R] ? R : never;

// curry, typed with variadic tuples
type Curried<A extends unknown[], R> =
  A extends [infer H, ...infer T] ? (a: H) => T extends [] ? R : Curried<T, R> : R;
declare function curry<A extends unknown[], R>(fn: (...a: A) => R): Curried<A, R>;
const add3 = curry((a: number, b: string, c: boolean) => `${a}${b}${c}`);
const r = add3(1)('x')(true);      // string

9.2 Typed zip

type Zip<A extends unknown[], B extends unknown[]> =
  A extends [infer HA, ...infer TA]
    ? B extends [infer HB, ...infer TB] ? [[HA, HB], ...Zip<TA, TB>] : []
    : [];
type _18 = Zip<[1, 2], ['a', 'b']>;     // [[1, 'a'], [2, 'b']]

10. The utility type library, reimplemented

Knowing the implementations means you can write the ones the standard library is missing.

UtilityImplementation
Partial<T>{ [K in keyof T]?: T[K] }
Required<T>{ [K in keyof T]-?: T[K] }
Readonly<T>{ readonly [K in keyof T]: T[K] }
Record<K, T>{ [P in K]: T }
Pick<T, K>{ [P in K]: T[P] }
Omit<T, K>Pick<T, Exclude<keyof T, K>>
Exclude<T, U>T extends U ? never : T (distributive)
Extract<T, U>T extends U ? T : never
NonNullable<T>T & {} (was Exclude<T, null | undefined>)
Parameters<F>F extends (...a: infer P) => any ? P : never
ConstructorParameters<C>C extends abstract new (...a: infer P) => any ? P : never
ReturnType<F>F extends (...a: any) => infer R ? R : any
InstanceType<C>C extends abstract new (...a: any) => infer R ? R : any
ThisParameterType<F>F extends (this: infer U, ...a: never) => any ? U : unknown
OmitThisParameter<F>strips the this parameter, preserving the rest
Awaited<T>recursive unwrap of PromiseLike, also unwrapping thenables
NoInfer<T>intrinsic (TS 5.4): blocks inference at this position
Uppercase/Lowercase/Capitalize/Uncapitalizecompiler intrinsics

Omit is not homomorphic (it goes through Pick over a computed key union), so it drops modifiers and flattens tuples. That is the answer to “why did Omit turn my tuple into an object”.

10.1 The custom utilities to know cold

All of these compile with the Assert<Equals<...>> machinery; the assertions shown are the ones that were actually run.

type Prettify<T> = { [K in keyof T]: T[K] } & {};       // forces the editor to expand intersections

type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
type _19 = Assert<Equals<DeepPartial<{ a: { b: number } }>, { a?: { b?: number } }>>;

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type _20 = Assert<Equals<Mutable<{ readonly a: 1 }>, { a: 1 }>>;

type _21 = Assert<Equals<UnionToIntersection<{ a: 1 } | { b: 2 }>, { a: 1 } & { b: 2 }>>;

type IsNever<T>   = [T] extends [never] ? true : false;
type IsAny<T>     = 0 extends (1 & T) ? true : false;
type IsUnknown<T> = IsAny<T> extends true ? false : unknown extends T ? true : false;

// LastOf abuses UnionToIntersection's overload-merging to get the LAST union member
type LastOf<U> = UnionToIntersection<U extends any ? () => U : never> extends () => infer R ? R : never;
type UnionToTuple<U, Acc extends unknown[] = []> =
  IsNever<U> extends true ? Acc : UnionToTuple<Exclude<U, LastOf<U>>, [LastOf<U>, ...Acc]>;
type _22 = Assert<Equals<UnionToTuple<'a' | 'b' | 'c'>, ['a', 'b', 'c']>>;

type Merge<A, B> = Prettify<Omit<A, keyof B> & B>;
type _23 = Assert<Equals<Merge<{ a: 1; b: 2 }, { b: 3; c: 4 }>, { a: 1; b: 3; c: 4 }>>;

type RequireAtLeastOne<T, K extends keyof T = keyof T> =
  Omit<T, K> & { [P in K]-?: Required<Pick<T, P>> & Partial<Pick<T, Exclude<K, P>>> }[K];

type Without<T, U> = { [K in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T);

XOR and RequireAtLeastOne were verified behaviourally: {a: 'y'} and {b: 1} are accepted, {a: 'y', b: 1} and {} are rejected (each rejection proved with a @ts-expect-error that fires).

UnionToTuple deserves a warning: it depends on the compiler’s internal ordering of union members, which is not specified. Use it for codegen and tests, not for production invariants.


11. Type-level programming

11.1 Arithmetic

The type system has no numbers, only tuple lengths — so arithmetic is tuple manipulation.

type BuildTuple<N extends number, A extends unknown[] = []> =
  A['length'] extends N ? A : BuildTuple<N, [...A, unknown]>;

type Add<A extends number, B extends number> =
  [...BuildTuple<A>, ...BuildTuple<B>]['length'] extends infer L extends number ? L : never;

type Sub<A extends number, B extends number> =
  BuildTuple<A> extends [...BuildTuple<B>, ...infer R] ? R['length'] : never;

type Range<N extends number, A extends number[] = []> =
  A['length'] extends N ? A : Range<N, [...A, A['length']]>;

type _24 = Assert<Equals<Add<3, 4>, 7>>;
type _25 = Assert<Equals<Sub<9, 4>, 5>>;
type _26 = Assert<Equals<Range<4>, [0, 1, 2, 3]>>;

Fibonacci, using two accumulators so it stays tail-recursive:

type Fib<N extends number, A extends unknown[] = [unknown], B extends unknown[] = [unknown]> =
  N extends 1 | 2 ? 1 : Sub<N, 1> extends infer M extends number ? FibStep<M, A, B> : never;
type FibStep<N extends number, A extends unknown[], B extends unknown[]> =
  N extends 1 ? B['length'] : FibStep<Sub<N, 1> & number, B, [...A, ...B]>;

type _27 = Assert<Equals<Fib<8>, 21>>;

The ceiling is the ~1,000 instantiation depth and the 10,000-element tuple limit, so numbers above a few hundred are out of reach. That is the point: this is a proof that the type system is Turing-complete in practice, not a technique for production code.

11.2 A type-safe event emitter

This is a genuinely useful application, and a common design question.

type EventMap = {
  'user:created': { id: string; email: string };
  'user:deleted': { id: string };
  'ping': void;
};

class TypedEmitter<M extends Record<string, unknown>> {
  #handlers = new Map<keyof M, Set<(p: unknown) => void>>();

  on<K extends keyof M>(ev: K, fn: (payload: M[K]) => void): () => void {
    const set = this.#handlers.get(ev) ?? new Set();
    this.#handlers.set(ev, set);
    set.add(fn as (p: unknown) => void);
    return () => set.delete(fn as (p: unknown) => void);
  }

  emit<K extends keyof M>(
    ev: K,
    ...args: M[K] extends void ? [] : [payload: M[K]]      // void events take no argument
  ): void {
    for (const fn of this.#handlers.get(ev) ?? []) fn(args[0]);
  }
}

const bus = new TypedEmitter<EventMap>();
bus.on('user:created', p => p.email.toLowerCase());   // p is fully typed
bus.emit('ping');                                     // no payload allowed
bus.emit('user:deleted', { id: '1' });
// bus.emit('user:deleted', { ident: '1' });           // error
// bus.on('typo', () => {});                           // error

The conditional rest parameter (...args: M[K] extends void ? [] : [payload: M[K]]) is the trick that makes payload-less events ergonomic. Same pattern types Redux actions, RPC clients, and IPC channels.

11.3 A typed route builder

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Routes = {
  'GET /users/:id': { params: { id: string }; response: { id: string; name: string } };
  'POST /users': { body: { name: string }; response: { id: string } };
};

type ReqInit<R> =
  (R extends { params: infer P } ? { params: P } : {}) &
  (R extends { body: infer B } ? { body: B } : {});

declare function api<K extends keyof Routes>(
  route: K, init: ReqInit<Routes[K]>
): Promise<Routes[K] extends { response: infer Res } ? Res : never>;

// api('GET /users/:id', { params: { id: 'u1' } })   -> Promise<{id: string; name: string}>
// api('POST /users', { body: { name: 'ana' } })     -> Promise<{id: string}>

11.4 A JSON type

type Json = string | number | boolean | null | Json[] | { [k: string]: Json };

// enforce that a type is JSON-serializable
type Serializable<T> =
  T extends Json ? T
  : T extends (...a: any[]) => any ? never
  : T extends Date ? string
  : T extends object ? { [K in keyof T]: Serializable<T[K]> }
  : never;

11.5 When type-level cleverness is a liability

The honest senior answer, and interviewers do ask:

  • Compile time. Deeply recursive conditional types are the number-one cause of slow tsc. Measure with tsc --diagnostics --extendedDiagnostics (watch “Instantiations” and “Check time”) and --generateTrace for a flame chart.
  • Error messages. A failed constraint on a 6-level recursive type produces an unreadable error. The team maintaining it after you will as any past it.
  • The alternative is usually better. A runtime schema (zod, valibot) plus z.infer gives you the type and the validation from one declaration.
  • Rule of thumb. Type-level code is worth it when it removes a whole class of caller mistakes at an API boundary (route names, event names, column names). It is not worth it inside your own module, where a test is cheaper.

12. Declaration space

12.1 interface vs type — the complete answer

This is the most-asked TypeScript interview question, and most answers are incomplete.

interfacetype
Objects and functionsyesyes
Unions, primitives, tuples, conditionals, mapped typesnoyes
Declaration mergingyesno
Extendsextends (eagerly checked)& (deferred)
Implementsyesyes (if object-shaped)
Recursive referencesyesyes
Compiler performance on large typesbetter — interfaces are cached by referenceintersections may be recomputed
Shows in errors asthe nameoften expanded

The differences that actually bite:

// 1. declaration merging — only interfaces
interface Mergeable { a: number }
interface Mergeable { b: string }
const merged: Mergeable = { a: 1, b: 'x' };      // both members exist

// 2. extends is checked eagerly; intersection defers to `never`
interface Base { p: string }
// @ts-expect-error interface extends reports the conflict at the declaration site
interface Bad extends Base { p: number }
type Inter = Base & { p: number };               // no error HERE...
type _28 = Assert<Equals<Inter['p'], never>>;    // ...but the property is unusable

So: interface when you are describing an object contract that others may augment (library types, declare module augmentation, React prop types you expect consumers to extend); type when you need a union, a tuple, a conditional, or a mapped type. In practice, use interface for object shapes and type for everything else, and know that the choice is mostly stylistic except for merging and the error-location difference above.

12.2 Module augmentation

// add a property to Express's Request
declare global {
  namespace Express { interface Request { user?: { id: string } } }
}
// augment a third-party module
declare module 'some-lib' {
  export interface Options { newFlag?: boolean }
}
// add to a global
declare global { interface Window { __APP_STATE__: unknown } }
export {};      // required: makes the file a module so `declare global` is legal

12.3 enum vs union of literals vs as const object

enum Color { Red = 'red', Blue = 'blue' }               // real runtime object
type Color2 = 'red' | 'blue';                           // zero runtime
const Color3 = { Red: 'red', Blue: 'blue' } as const;   // runtime object + literal types
type Color3 = typeof Color3[keyof typeof Color3];
Runtime costIterableNominal-ishNotes
enum (string)an objectyesyesnot structurally comparable to a raw string
enum (numeric)an object with reverse mappingyesweaklyaccepts any number in older TS; avoid
const enuminlined, no objectnoyesbreaks under isolatedModules; removed as an option to rely on in TS 7 workflows
union of literalsnonenonobest for most cases
as const object + derived unionone objectyesnobest when you need to iterate the values

The modern recommendation is the as const object plus derived union: you get the values at runtime, literal types at compile time, no enum semantics to explain, and full erasability (which erasableSyntaxOnly and TS 7 care about).

12.4 Decorators

TC39 standard decorators (TS 5.0+, experimentalDecorators off):

function logged<This, Args extends unknown[], R>(
  target: (this: This, ...args: Args) => R,
  ctx: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>
) {
  return function (this: This, ...args: Args): R {
    console.log(`-> ${String(ctx.name)}`, args);
    const out = target.call(this, ...args);
    console.log(`<- ${String(ctx.name)}`, out);
    return out;
  };
}

class Calc {
  @logged
  add(a: number, b: number) { return a + b; }
}
new Calc().add(2, 3);

Differences from the legacy (experimentalDecorators) form: the signature is (value, context) rather than (target, key, descriptor); there is no emitDecoratorMetadata (so Angular/NestJS-style DI by type reflection still needs the legacy flag); addInitializer handles per-instance setup; accessor fields and auto-accessors are new; and evaluation order is specified. Legacy and standard decorators cannot be mixed in a project.


13. Compiler, config, and the 6.0 to 7.0 transition

TypeScript 6.0 (March 2026) was the final JavaScript-implemented release; it exists mainly to deprecate what 7.0 removes and to give you a warning-clean migration target. TypeScript 7.0 (August 2026) is the Go rewrite (tsgo), roughly 10x faster type-checking and builds, with shared-memory parallelism.

What 7.0 changes that will affect a real codebase:

ChangeWhat to do
strict defaults to trueset it explicitly either way in tsconfig.json
target: es5 removedmove to es2017+, or transpile with a bundler
downlevelIteration removedconsequence of the above
moduleResolution: node/node10 and classic removedbundler or node16/nodenext
AMD, UMD, SystemJS, module: none removeduse ESM and let a bundler emit the format
baseUrl removeduse relative paths or paths with explicit roots
module now defaults to esnext, types defaults to []list your @types packages explicitly
stableTypeOrdering always onunion/intersection member order is now deterministic
--ignoreDeprecations no longer worksfix the deprecations
Some JSDoc forms dropped (postfix !, @enum, @class)migrate JS-with-JSDoc projects
New --checkers N (default 4) and --builderstune for your CI machine

The flags worth setting deliberately in any new project:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,     // import/export elision is exactly what you wrote
    "isolatedModules": true,          // each file transpilable alone (required by esbuild/swc)
    "erasableSyntaxOnly": true,       // bans enum/namespace/parameter properties -> plain-JS-compatible
    "moduleResolution": "bundler",
    "module": "esnext",
    "target": "es2022",
    "skipLibCheck": true              // pragmatic: do not type-check node_modules .d.ts
  }
}

verbatimModuleSyntax plus import type is what makes the difference between an import that is erased and one that survives for its side effects — a real source of bugs when a bundler drops a polyfill import.


14. Practical patterns

14.1 Result instead of exceptions

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

const Ok  = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });

function parseIntSafe(s: string): Result<number, 'NaN'> {
  const n = Number(s);
  return Number.isFinite(n) ? Ok(n) : Err('NaN');
}

const r = parseIntSafe('12');
if (r.ok) r.value.toFixed();      // narrowed
else r.error;                     // 'NaN'

// chaining
const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
  r.ok ? Ok(f(r.value)) : r;

The argument for it: throw is untyped in TypeScript (there is no throws clause), so the type system cannot help you handle errors exhaustively. Result puts the error in the return type where a discriminated union can force you to handle it. The argument against: it is viral, and it fights the ecosystem. Use it at boundaries (parsing, IO, validation) rather than everywhere.

14.2 Typing a reducer

type Action =
  | { type: 'add'; payload: { id: string; text: string } }
  | { type: 'toggle'; payload: { id: string } }
  | { type: 'clear' };

type State = { todos: Array<{ id: string; text: string; done: boolean }> };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':    return { todos: [...state.todos, { ...action.payload, done: false }] };
    case 'toggle': return { todos: state.todos.map(t => t.id === action.payload.id ? { ...t, done: !t.done } : t) };
    case 'clear':  return { todos: [] };
    default: { const _e: never = action; return state; }
  }
}

// derive the action-creator map from the union, so creators cannot drift from the reducer
type Creators = { [A in Action as A['type']]: A extends { payload: infer P } ? (p: P) => A : () => A };

14.3 Overloads vs unions vs generics

SituationUse
Return type depends on the number or literal value of argumentsoverloads
Argument can be one of several types, return type is the sameunion parameter
Return type is a function of the argument typegeneric
Return type depends on a boolean flagoverloads, or a conditional return type with a generic
// overloads: distinct signatures, one implementation
function el(tag: 'a'): HTMLAnchorElement;
function el(tag: 'input'): HTMLInputElement;
function el(tag: string): HTMLElement;
function el(tag: string): HTMLElement { return document.createElement(tag); }

// generic + conditional return, when there are too many cases to enumerate
declare function query<T extends boolean>(sql: string, single: T): T extends true ? Row : Row[];

Overload resolution picks the first matching signature, which is why you order from most to least specific, and why the implementation signature is not callable from outside.

14.4 Typing middleware

type Middleware<Ctx> = (ctx: Ctx, next: () => Promise<void>) => Promise<void>;

// middleware that *adds* to the context — the type grows through the chain
type Extend<Ctx, Add> = Middleware<Ctx> & { __adds?: Add };

function compose<Ctx>(...mws: Array<Middleware<Ctx>>): Middleware<Ctx> {
  return (ctx, next) => {
    let i = -1;
    const dispatch = (n: number): Promise<void> => {
      if (n <= i) throw new Error('next() called twice');
      i = n;
      const fn = n === mws.length ? next : mws[n];
      return fn ? Promise.resolve(fn(ctx, () => dispatch(n + 1))) : Promise.resolve();
    };
    return dispatch(0);
  };
}

15. Interview questions

Q: interface vs type — give me three real differences, not stylistic ones.

A: Declaration merging (interfaces only); extends reports a conflicting member at the declaration site while an intersection silently produces never for that member; and interfaces are cheaper for the checker on large types because they are cached by reference. Only type can express unions, tuples, conditionals and mapped types.

Q: any vs unknown vs never — when do you use each?

A: unknown at every untrusted boundary (JSON.parse, catch, external input) because it forces narrowing. never for impossible states and exhaustiveness checks. any only while migrating, and ideally as // eslint-disable with a comment.

Q: What does satisfies do that an annotation does not?

A: It validates the value against a type without widening it, so literal types survive. Shown above: withAnnotation['x'] is string | number, withSatisfies['x'] is number.

Q: Why does this compile? const animals: Animal[] = dogs; animals.push(cat);

A: Arrays are covariant in TypeScript — a deliberate unsoundness for ergonomics. Use readonly Animal[] when you only read.

Q: Why is cmp(a: Dog): void assignable to cmp(a: Animal): void but the arrow-property version is not?

A: Method-shorthand parameters are bivariant (needed to keep Array<T>’s methods compatible); function-typed properties are contravariant under strictFunctionTypes. Declare callbacks as properties to get the safe behaviour.

Q: What is a distributive conditional type and how do you stop distribution?

A: A conditional with a naked type parameter on the left distributes over unions. Wrap both sides in tuples: [T] extends [U]. This is why IsNever<T> must be written [T] extends [never].

Q: Why is T extends never ? true : false not true when T is never?

A: never is the empty union, so a distributive conditional has nothing to distribute over and the result is never.

Q: How do you detect any at the type level?

A: 0 extends (1 & T) ? true : false. 1 & any is any, and 0 extends any holds; for any other T, 1 & T is a type that 0 cannot extend.

Q: What is a homomorphic mapped type and why does it matter?

A: One written exactly {[K in keyof T]: ...}. It preserves readonly/? modifiers and maps over arrays/tuples/unions structurally. Omit is not homomorphic, which is why Omit on a tuple gives you an object.

Q: Explain UnionToIntersection.

A: It puts the union in a contravariant position ((x: U) => void), and the rule for assigning a union of function types to one signature intersects the parameter types, so infer I yields the intersection.

Q: How do you make an exhaustive switch?

A: Discriminated union plus const _e: never = value in the default. Adding a member breaks the build at that line.

Q: How do you type “at least one of these properties”?

A: RequireAtLeastOne<T, K> as shown — an intersection of the non-K part with a union of “this key required, the rest optional” objects, indexed by [K].

Q: Why does Object.keys(obj) return string[] and not (keyof T)[]?

A: Because structural typing allows extra properties at runtime — obj may hold more keys than its type mentions. A keyof T return would be unsound. Cast deliberately with a helper if you own the object.

Q: What is the difference between readonly T[] and ReadonlyArray<T>?

A: Nothing — the first is sugar. Readonly<T[]> is also equivalent. as const on an array literal produces a readonly tuple, which is stronger.

Q: Are enums bad?

A: Numeric enums are: they are not exhaustively checked in older versions, they emit a reverse mapping, and they are nominal in a structural language. String enums are fine but still emit runtime code and break erasableSyntaxOnly. The as const object plus derived union is usually better.

Q: What does declare mean?

A: “This exists at runtime, do not emit anything for it.” Used in .d.ts files, ambient declarations, and class fields you know are assigned elsewhere (declare foo: string to avoid an emitted assignment that would shadow a base-class accessor).

Q: How does TypeScript infer generic type parameters?

A: By matching argument types against parameter types at inference sites, collecting candidates, and picking the best common supertype. Later parameters can widen an earlier inference — NoInfer<T> opts a position out.

Q: What is keyof any?

A: string | number | symbol — the set of valid property keys. That is why Record<K extends keyof any, T> is written that way.

Q: What is the type of []? Of {}?

A: never[] (or any[] without strictNullChecks) and {}. Note {} is not “empty object” — it is “anything except null/undefined”.

Q: Explain as const.

A: It makes an object/array literal deeply readonly and stops literal widening, so {a: 1} has type {readonly a: 1} rather than {a: number}. Essential for config maps you index into.

Q: How do you handle catch (e) correctly?

A: With useUnknownInCatchVariables, e is unknown. Narrow it: e instanceof Error ? e.message : String(e). Anything can be thrown in JavaScript, including strings and undefined.

Q: How do you type a function that returns different shapes based on a boolean argument?

A: Overloads for two or three cases; a generic with a conditional return type (<T extends boolean>(flag: T) => T extends true ? A : B) when the flag is passed through.

Q: What is a discriminated union and why is it better than optional fields?

A: A union of object types sharing a literal-typed tag. It makes illegal states unrepresentable — {status: 'loading'} | {status: 'ok'; data: T} | {status: 'error'; error: E} cannot have data and error at once, while {loading?: boolean; data?: T; error?: E} can be in 8 states, most nonsense.

Q: How would you speed up a slow tsc?

A: --diagnostics --extendedDiagnostics to find whether it is parse, bind, check or emit; --generateTrace for a flame chart; skipLibCheck; project references with composite; replace deep recursive conditional types; add explicit return type annotations to break inference cycles; and on TS 7, --checkers N to use more cores.

Q: What is the resulting type? type X = 'a' | 'b' extends string ? 1 : 2

A: 1. This is not a distributive position (the left side is not a naked type parameter), so the whole union is tested at once and 'a' | 'b' does extend string.

Q: What is the resulting type? type Y<T> = T extends { a: infer U; b: infer U } ? U : never; type Z = Y<{ a: string; b: number }>

A: string | number. Multiple infer of the same name in covariant positions produce a union.

Q: What is the resulting type? type W = Exclude<keyof { a: 1; b: 2 }, 'a'>

A: 'b'. keyof gives 'a' | 'b', and Exclude distributes.

Q: Omit<T, K> where K is not a key of T — error or allowed?

A: Allowed, because Omit’s K is keyof any, not keyof T. That is a real source of typos surviving a rename. A stricter StrictOmit<T, K extends keyof T> is a common house utility.


Next: Python core, or the structures these types describe in Data structures in TypeScript.

Verify it yourself

ts-v/u.ts

// ---- assertion machinery ----
export type Equals<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
export type Assert<T extends true> = T;


// ---- utilities ----
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
type DeepReadonly<T> = T extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>>
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
type IsNever<T> = [T] extends [never] ? true : false;
type IsAny<T> = 0 extends (1 & T) ? true : false;
type IsUnknown<T> = IsAny<T> extends true ? false : unknown extends T ? true : false;
type LastOf<U> = UnionToIntersection<U extends any ? () => U : never> extends () => infer R ? R : never;
type UnionToTuple<U, Acc extends unknown[] = []> =
  IsNever<U> extends true ? Acc : UnionToTuple<Exclude<U, LastOf<U>>, [LastOf<U>, ...Acc]>;
type Split<S extends string, D extends string> =
  S extends `${infer H}${D}${infer T}` ? [H, ...Split<T, D>] : [S];
type Join<T extends readonly string[], D extends string> =
  T extends readonly [] ? '' :
  T extends readonly [infer O extends string] ? O :
  T extends readonly [infer H extends string, ...infer R extends string[]] ? `${H}${D}${Join<R, D>}` : string;
type Reverse<T extends unknown[]> = T extends [infer H, ...infer R] ? [...Reverse<R>, H] : [];
type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];
type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K }[keyof T];
type RequireAtLeastOne<T, K extends keyof T = keyof T> =
  Omit<T, K> & { [P in K]-?: Required<Pick<T, P>> & Partial<Pick<T, Exclude<K, P>>> }[K];
type Without<T, U> = { [K in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T);
type Merge<A, B> = Prettify<Omit<A, keyof B> & B>;
type PickByValue<T, V> = Pick<T, { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T]>;
type CamelCase<S extends string> = S extends `${infer H}_${infer T}` ? `${H}${Capitalize<CamelCase<T>>}` : S;
type SnakeCase<S extends string, Acc extends string = ''> =
  S extends `${infer H}${infer T}`
    ? SnakeCase<T, `${Acc}${H extends Uppercase<H> ? (H extends Lowercase<H> ? H : `_${Lowercase<H>}`) : H}`>
    : Acc;
type BuildTuple<N extends number, A extends unknown[] = []> = A['length'] extends N ? A : BuildTuple<N, [...A, unknown]>;
type Add<A extends number, B extends number> = [...BuildTuple<A>, ...BuildTuple<B>]['length'] extends infer L extends number ? L : never;
type Sub<A extends number, B extends number> = BuildTuple<A> extends [...BuildTuple<B>, ...infer R] ? R['length'] : never;
type Range<N extends number, A extends number[] = []> = A['length'] extends N ? A : Range<N, [...A, A['length']]>;
type Fib<N extends number, A extends unknown[] = [unknown], B extends unknown[] = [unknown]> =
  N extends 1 | 2 ? 1 : Sub<N, 1> extends infer M extends number ? FibStep<M, A, B> : never;
type FibStep<N extends number, A extends unknown[], B extends unknown[]> =
  N extends 1 ? B['length'] : FibStep<Sub<N, 1> & number, B, [...A, ...B]>;

type Paths<T, P extends string = ''> = T extends object
  ? { [K in keyof T & string]: P extends '' ? K | Paths<T[K], K> : `${P}.${K}` | Paths<T[K], `${P}.${K}`> }[keyof T & string]
  : never;
type PathValue<T, P extends string> =
  P extends `${infer H}.${infer R}` ? (H extends keyof T ? PathValue<T[H], R> : never)
  : P extends keyof T ? T[P] : never;

type ParseQuery<S extends string> = Prettify<UnionToIntersection<
  Split<S, '&'>[number] extends infer Pair
    ? Pair extends `${infer K}=${infer V}` ? { [P in K]: V } : never
    : never>>;
type RouteParams<S extends string> =
  S extends `${string}:${infer P}/${infer R}` ? { [K in P]: string } & RouteParams<`/${R}`>
  : S extends `${string}:${infer P}` ? { [K in P]: string } : {};

// ---- assertions (compile-time verified) ----
type _1 = Assert<Equals<DeepPartial<{a:{b:number}}>, {a?:{b?:number}}>>;
type _2 = Assert<Equals<Mutable<{readonly a:1}>, {a:1}>>;
type _3 = Assert<Equals<UnionToIntersection<{a:1}|{b:2}>, {a:1}&{b:2}>>;
type _4 = Assert<Equals<IsNever<never>, true>>;
type _5 = Assert<Equals<IsAny<any>, true>>;
type _6 = Assert<Equals<IsUnknown<unknown>, true>>;
type _7 = Assert<Equals<Split<'a.b.c','.'>, ['a','b','c']>>;
type _8 = Assert<Equals<Join<['a','b','c'],'-'>, 'a-b-c'>>;
type _9 = Assert<Equals<Reverse<[1,2,3]>, [3,2,1]>>;
type _10 = Assert<Equals<OptionalKeys<{a:1;b?:2}>, 'b'>>;
type _11 = Assert<Equals<RequiredKeys<{a:1;b?:2}>, 'a'>>;
type _12 = Assert<Equals<Merge<{a:1;b:2},{b:3;c:4}>, {a:1;b:3;c:4}>>;
type _13 = Assert<Equals<PickByValue<{a:string;b:number;c:string}, string>, {a:string;c:string}>>;
type _14 = Assert<Equals<CamelCase<'foo_bar_baz'>, 'fooBarBaz'>>;
type _15 = Assert<Equals<SnakeCase<'fooBarBaz'>, 'foo_bar_baz'>>;
type _16 = Assert<Equals<Add<3,4>, 7>>;
type _17 = Assert<Equals<Sub<9,4>, 5>>;
type _18 = Assert<Equals<Range<4>, [0,1,2,3]>>;
type _19 = Assert<Equals<Fib<8>, 21>>;
type _20 = Assert<Equals<PathValue<{a:{b:{c:string}}}, 'a.b.c'>, string>>;
type _21 = Assert<Equals<ParseQuery<'x=1&y=2'>, {x:'1';y:'2'}>>;
type _22 = Assert<Equals<RouteParams<'/users/:id/posts/:postId'>, {id:string}&{postId:string}>>;
type _23 = Assert<Equals<UnionToTuple<'a'|'b'|'c'>, ['a','b','c']>>;
type _24 = Assert<Equals<Paths<{a:{b:string}}>, 'a'|'a.b'>>;
type _25 = Assert<Equals<DeepReadonly<{a:number[]}>, {readonly a: ReadonlyArray<number>}>>;
// XOR sanity
declare const x1: XOR<{a:string},{b:number}>;
const ok1: typeof x1 = { a: 'y' };
const ok2: typeof x1 = { b: 1 };
// @ts-expect-error both branches at once is rejected
const bad1: typeof x1 = { a: 'y', b: 1 };
// RequireAtLeastOne sanity
declare const r1: RequireAtLeastOne<{a?:string;b?:number}>;
const rok: typeof r1 = { a: 'x' };
// @ts-expect-error empty object rejected
const rbad: typeof r1 = {};

export {};

ts-v/v.ts

// 1. method shorthand is bivariant, property syntax is contravariant under strictFunctionTypes
interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface WithMethod { cmp(a: Dog): void }
interface WithProp   { cmp: (a: Dog) => void }
const mOk: WithMethod = { cmp: (a: Animal) => {} };   // ok (contravariant direction, fine)
const mBiv: WithMethod = { cmp: (a: Dog) => {} };
declare const wm: WithMethod;
const asAnimalMethod: { cmp(a: Animal): void } = wm;  // ok ONLY because methods are bivariant
// @ts-expect-error property syntax is strictly contravariant
const asAnimalProp: { cmp: (a: Animal) => void } = ({} as WithProp);

// 2. array covariance is unsound
const dogs: Dog[] = [{ name: 'rex', breed: 'lab' }];
const animals: Animal[] = dogs;          // allowed: arrays are covariant
animals.push({ name: 'cat' });           // compiles, but dogs[1].breed is now undefined at runtime

// 3. satisfies vs annotation vs assertion
const a1 = { x: 1, y: 'a' } as const;
const withAnnotation: Record<string, number | string> = { x: 1, y: 'a' };
const withSatisfies = { x: 1, y: 'a' } satisfies Record<string, number | string>;
type T1 = typeof withAnnotation['x'];   // string | number  (widened)
type T2 = typeof withSatisfies['x'];    // number           (narrow preserved)
type Chk1 = Assert<Equals<T1, string | number>>;
type Chk2 = Assert<Equals<T2, number>>;

// 4. interface vs type: declaration merging
interface Mergeable { a: number }
interface Mergeable { b: string }
const merged: Mergeable = { a: 1, b: 'x' };

// 5. interface extends checks eagerly, intersection defers
interface Base { p: string }
// @ts-expect-error interface extends reports the conflict at the declaration site
interface Bad extends Base { p: number }
type Inter = Base & { p: number };       // no error here; p becomes string & number = never
type Chk3 = Assert<Equals<Inter['p'], never>>;

// 6. distributive vs non-distributive conditional
type Distr<T> = T extends string ? 'S' : 'N';
type NonDistr<T> = [T] extends [string] ? 'S' : 'N';
type Chk4 = Assert<Equals<Distr<string | number>, 'S' | 'N'>>;
type Chk5 = Assert<Equals<NonDistr<string | number>, 'N'>>;

// 7. exhaustiveness with never
type Shape = { k: 'c'; r: number } | { k: 's'; a: number };
function area(s: Shape): number {
  switch (s.k) {
    case 'c': return Math.PI * s.r ** 2;
    case 's': return s.a ** 2;
    default: { const _e: never = s; return _e; }
  }
}

// 8. NoInfer
function pick<T>(items: T[], fallback: NoInfer<T>): T { return items[0] ?? fallback; }
pick(['a', 'b'], 'c');
// @ts-expect-error fallback no longer widens T
pick(['a', 'b'], 42);

type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Assert<T extends true> = T;
export {};