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
- 1. Mental model
- 2. The type lattice
- 3. Narrowing and control flow analysis
- 4. Generics
- 5. Variance
- 6. Conditional types
- 7. Mapped types
- 8. Template literal types
- 9. Tuples and variadic types
- 10. The utility type library, reimplemented
- 11. Type-level programming
- 12. Declaration space
- 13. Compiler, config, and the 6.0 to 7.0 transition
- 14. Practical patterns
- 15. Interview questions
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:
- 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.)
- 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. - The checker is a proof assistant with an unsound escape hatch.
anyandasare 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, andobjectindex 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)
| Type | Assignable to it | Assignable from it | Use it for |
|---|---|---|---|
any | everything | everything | never, if you can help it |
unknown | everything | nothing without narrowing | the return of JSON.parse, catch bindings, boundaries |
never | nothing | everything | impossible states, exhaustiveness, functions that never return |
void | undefined (and null without strictNullChecks) | assignable to nothing useful | “I do not care about the return value” |
undefined | undefined | undefined, void, any, unknown | absence of a value |
object | any non-primitive | non-primitives | “not a primitive” |
{} | anything except null/undefined | — | almost nothing; it is not “empty object” |
Object | almost everything (has toString etc.) | — | never |
Record<string, unknown> | objects with string keys | — | the 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
| Flag | Effect | Worth turning on? |
|---|---|---|
strictNullChecks | null/undefined are no longer members of every type | non-negotiable |
strictFunctionTypes | function-type parameters become contravariant (methods stay bivariant) | yes |
strictPropertyInitialization | class fields must be assigned in the constructor | yes |
strictBindCallApply | bind/call/apply are type-checked | yes |
noImplicitAny | untyped parameters error | yes |
noImplicitThis | this of unknown type errors | yes |
useUnknownInCatchVariables | catch (e) is unknown instead of any | yes |
noUncheckedIndexedAccess | arr[i] is T | undefined | yes, and it is the one that finds real bugs |
exactOptionalPropertyTypes | {a?: string} no longer accepts {a: undefined} | yes, if your code can take it |
noImplicitOverride | override keyword required | yes |
noPropertyAccessFromIndexSignature | index-signature members need bracket access | taste |
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
| Form | Checks the value | Keeps the narrow inferred type | Can lie |
|---|---|---|---|
const x: T = v (annotation) | yes | no — x is T | no |
const x = v satisfies T | yes | yes | no |
const x = v as T (assertion) | no (only bidirectional-ish) | replaces the type | yes |
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>?
| Variance | Rule | Where |
|---|---|---|
| Covariant | F<Dog> assignable to F<Animal> | outputs: return types, readonly properties, Promise<T>, arrays (unsoundly) |
| Contravariant | F<Animal> assignable to F<Dog> | inputs: function parameters under strictFunctionTypes |
| Bivariant | both directions | method parameters (deliberate unsoundness) |
| Invariant | neither | mutable 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.
| Utility | Implementation |
|---|---|
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/Uncapitalize | compiler 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 withtsc --diagnostics --extendedDiagnostics(watch “Instantiations” and “Check time”) and--generateTracefor 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 anypast it. - The alternative is usually better. A runtime schema (zod, valibot) plus
z.infergives 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.
interface | type | |
|---|---|---|
| Objects and functions | yes | yes |
| Unions, primitives, tuples, conditionals, mapped types | no | yes |
| Declaration merging | yes | no |
| Extends | extends (eagerly checked) | & (deferred) |
| Implements | yes | yes (if object-shaped) |
| Recursive references | yes | yes |
| Compiler performance on large types | better — interfaces are cached by reference | intersections may be recomputed |
| Shows in errors as | the name | often 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 cost | Iterable | Nominal-ish | Notes | |
|---|---|---|---|---|
enum (string) | an object | yes | yes | not structurally comparable to a raw string |
enum (numeric) | an object with reverse mapping | yes | weakly | accepts any number in older TS; avoid |
const enum | inlined, no object | no | yes | breaks under isolatedModules; removed as an option to rely on in TS 7 workflows |
| union of literals | none | no | no | best for most cases |
as const object + derived union | one object | yes | no | best 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:
| Change | What to do |
|---|---|
strict defaults to true | set it explicitly either way in tsconfig.json |
target: es5 removed | move to es2017+, or transpile with a bundler |
downlevelIteration removed | consequence of the above |
moduleResolution: node/node10 and classic removed | bundler or node16/nodenext |
AMD, UMD, SystemJS, module: none removed | use ESM and let a bundler emit the format |
baseUrl removed | use relative paths or paths with explicit roots |
module now defaults to esnext, types defaults to [] | list your @types packages explicitly |
stableTypeOrdering always on | union/intersection member order is now deterministic |
--ignoreDeprecations no longer works | fix the deprecations |
Some JSDoc forms dropped (postfix !, @enum, @class) | migrate JS-with-JSDoc projects |
New --checkers N (default 4) and --builders | tune 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
| Situation | Use |
|---|---|
| Return type depends on the number or literal value of arguments | overloads |
| Argument can be one of several types, return type is the same | union parameter |
| Return type is a function of the argument type | generic |
| Return type depends on a boolean flag | overloads, 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 {};