TypeScript Generics, Explained Through Interview Questions
Ten generics questions, from constraints and keyof to infer, distributive conditional types and variance, each with a short answer and code you can reproduce.
Types that make interviewers nod: generics, narrowing, utility types, conditional and mapped types.
Ten generics questions, from constraints and keyof to infer, distributive conditional types and variance, each with a short answer and code you can reproduce.
Declaration merging, extends vs intersections, what only type aliases can express, and the few differences that change behavior, with a clear rule of thumb.
Reimplement Partial, Pick, Omit, Exclude, ReturnType, Awaited and friends with mapped and conditional types, plus modifiers, key remapping and template literals.
TypeScript is JavaScript with a static type system on top. You write JavaScript plus type annotations, the compiler checks them, and then the types are erased: the output is plain JavaScript, so there is no runtime cost and runtime behavior doesn't change.
Why teams adopt it:
null or undefined.The trade-offs are a build or type-check step, a learning curve, and the fact that types can lie: any, careless assertions and unvalidated API data all bypass the checker, and nothing is enforced at runtime.
Likely follow-up: Does TypeScript make your code run faster? · What are the downsides of adopting TypeScript?
type and interface? When would you use each?easyFor object shapes they're nearly interchangeable: both can be generic, extended, and implemented by a class. The real differences:
interface User declarations merge into one, which is how you augment Window or a library's types. A duplicate type is an error.extends, which reports conflicting properties. Type aliases compose with &, where a conflict silently becomes never.type can name unions, tuples, primitives, and mapped, conditional or template literal types.Record<string, T>; an interface isn't.My default is interface for object shapes, especially ones others extend or augment, and type for everything else. Consistency within the team matters more than the choice.
interface User { id: number }
interface User { name: string } // merged: { id; name }
type Id = string | number; // only a type alias can name a union
type Base = { id: string };
type Broken = Base & { id: number }; // no error, but id is never
interface Derived extends Base { id: number } // Error: incorrectly extendstype is an errorextends reports conflicts; & silently yields nevertypeLikely follow-up: How would you add a property to the global Window type?
any, unknown and never.easyany switches type checking off. You can assign it anywhere and call anything on it, and it spreads silently through every expression it touches. It's an escape hatch, mostly for migrations.unknown is the type-safe "could be anything". Every value is assignable to it, but you can't use it (access properties, call it, assign it to a typed variable) until you narrow it with typeof, instanceof, a type guard or runtime validation. It's the right type for JSON.parse output, catch variables and untrusted input.never is the empty type: no value has it. It's the return type of functions that always throw or loop forever, the type left over after exhaustive narrowing, and it disappears from unions (string | never is string).So unknown is the top type, never is the bottom type, and any is an opt-out of the type system. Prefer unknown whenever you're tempted to write any.
let a: any = 'hi';
a.foo.bar(); // compiles, crashes at runtime
let u: unknown = 'hi';
u.toUpperCase(); // Error: 'u' is of type 'unknown'
if (typeof u === 'string') u.toUpperCase(); // OK after narrowing
function fail(msg: string): never {
throw new Error(msg);
}any disables checking and spreads silentlyunknown accepts anything but must be narrowed before usenever has no values: throwing functions, impossible branchesnever vanishes from unionsunknown over any for untrusted dataLikely follow-up: Where does never show up without you writing it?
any?easyA generic is a type parameter: a placeholder for a type that gets filled in each time a function, class or type is used, usually inferred from the arguments. It lets you write reusable code while keeping the relationship between inputs and outputs.
With any, a first(arr) helper returns any and every use of the result is unchecked. With first<T>(arr: T[]): T | undefined, calling it on a string[] returns string | undefined, so the type flows through.
You use generics constantly: Array<T>, Promise<T>, Map<K, V>, React's useState. When inference can't work out the type, you pass it explicitly, like useState<User | null>(null).
A good smell test: a type parameter should appear at least twice, linking a parameter to the return type or to another parameter. If it's used only once, a plain type annotation is usually enough.
any discards type information; generics keep itLikely follow-up: How do you restrict which types a generic accepts?
A union A | B means the value is one of the member types. Until you narrow it, you can only use what all members have in common: on string | number you can call toString() but not toUpperCase().
An intersection A & B means the value is both at once, so it has every property of A and every property of B. It's mostly used to combine object types, like User & { permissions: string[] }.
The naming feels backwards at first: a union accepts more values but lets you do less with them, while an intersection accepts fewer values but guarantees more properties.
Intersecting incompatible types produces never: string & number is never, and if two object types disagree on a property, that property becomes never (or the whole type does, for conflicting literal discriminants).
A | B: the value is one of the membersA & B: the value satisfies both typesstring & number become neverNarrowing is TypeScript's control-flow analysis refining a broad type, usually a union, to a more specific one inside a branch, based on the checks you write. It understands:
typeof x === 'string' for primitives (remember typeof null is 'object').x instanceof Date for class instances.'swim' in animal to check that a property exists.x === null, x !== undefined, or comparing a literal discriminant like shape.kind === 'circle'.if (x) removes null and undefined, but also 0 and ''.Array.isArray(x), and your own type predicates (x is Fish) and assertion functions.Narrowing follows early returns and throws too: after if (!user) return;, user is non-null for the rest of the function. In the last branch of an exhaustive check, what's left is never.
function format(value: string | number | Date | null) {
if (value === null) return 'n/a';
if (typeof value === 'number') return value.toFixed(2);
if (value instanceof Date) return value.toISOString();
return value.toUpperCase(); // only string is left
}typeof, instanceof, in, equality and truthiness checksLikely follow-up: Why is if (value) risky when value can be a number or a string?
The everyday ones:
Partial<T> makes every property optional (update payloads, patch functions); Required<T> removes the ? from every property.Readonly<T> marks every property readonly.Pick<T, K> keeps only the keys K; Omit<T, K> drops them.Record<K, V> builds an object type with keys K and values V, like Record<Status, string>.Exclude<U, X> and Extract<U, X> filter the members of a union; NonNullable<T> removes null and undefined.ReturnType<F>, Parameters<F> and Awaited<T> derive types from functions and promises.They compose well, for example Partial<Omit<User, 'id'>> for an update form. They're all just mapped and conditional types under the hood, so they're easy to reimplement. One gotcha: Partial and Readonly are shallow, they only affect top-level properties.
interface User { id: number; name: string; email?: string }
type UserPatch = Partial<Omit<User, 'id'>>; // { name?: string; email?: string }
type UserPreview = Pick<User, 'id' | 'name'>;
type CompleteUser = Required<User>; // email is now required
type UsersById = Record<number, User>;
type FrozenUser = Readonly<User>; // shallowPartial, Required, Readonly change property modifiersPick and Omit select or drop keysRecord<K, V> builds a keyed object typeExclude, Extract, NonNullable filter union membersLikely follow-up: How would you implement Partial yourself? · How would you write a DeepPartial?
enum or a union of string literals? And what is a const enum?midA literal union like type Status = 'idle' | 'loading' is pure type: it's erased, costs nothing at runtime, accepts plain strings from JSON, and narrows well.
An enum is one of the few TypeScript features that emits runtime code: a real object you can reference or iterate. The quirks:
'idle' where Status.Idle is expected, which is awkward with API data.number-typed value.erasableSyntaxOnly, the mode meant for runtimes that simply strip types.A const enum is inlined at each use site and emits no object, but tools that compile one file at a time (Babel, esbuild, SWC) can't inline one imported from another file, so many teams avoid it.
My default is a literal union, or an as const object plus a derived union when I also need the values at runtime.
enum Status { Idle = 'idle', Loading = 'loading' }
const s1: Status = Status.Idle;
const s2: Status = 'idle'; // Error: string enums are nominal
type StatusU = 'idle' | 'loading';
const s3: StatusU = 'idle'; // fine, and erased at runtime
const STATUS = { Idle: 'idle', Loading: 'loading' } as const;
type StatusC = (typeof STATUS)[keyof typeof STATUS]; // 'idle' | 'loading'const enum inlines values but trips up per-file transpilerserasableSyntaxOnlyas const object plus derived union as an alternativeType compatibility is decided by shape, not by name or declaration. If a value has at least the members a type requires, with compatible types, it's assignable, even if it was never declared as that type. That matches how JavaScript objects are actually used ("duck typing"), and it contrasts with nominal languages like Java or C#, where a class must explicitly declare that it implements an interface.
Consequences:
implements.The downside is that you can't tell a UserId from an OrderId if both are just string. That's what branded types are for. (Classes with private or #private members are the exception: they only match their own declaration.)
interface Point { x: number; y: number }
class Vec { constructor(public x: number, public y: number) {} }
function len(p: Point) { return Math.hypot(p.x, p.y); }
len(new Vec(3, 4)); // OK: same shape, no implements needed
const p3 = { x: 1, y: 2, z: 3 };
len(p3); // OK: extra property via a variable
len({ x: 1, y: 2, z: 3 }); // Error: excess property check on a literalimplementsas) and the non-null assertion (!) do, and why are they risky?easyA type assertion, value as Type, tells the compiler "trust me, it's this type". It performs no runtime check or conversion; it's simply erased. TypeScript only allows it when the two types overlap enough (one is assignable to the other); otherwise you need a double assertion through unknown, which is a red flag in review.
The non-null assertion x! removes null and undefined from the type of x.
Both are risky because if you're wrong, nothing complains at compile time and the failure appears at runtime, often far from the cause. They also keep silencing the compiler after the surrounding code changes.
Reasonable uses: DOM lookups where you control the markup, like querySelector('#c') as HTMLCanvasElement, and test code. Otherwise prefer narrowing, type guards, satisfies, or runtime validation. (as const is different: it's a const assertion that makes a literal more specific, not less safe.)
const root = document.getElementById('app')!; // HTMLElement, trusted to exist
const canvas = document.querySelector('#c') as HTMLCanvasElement;
const n = 'hello' as number; // Error: types don't overlap
const forced = 'hello' as unknown as number; // compiles, but it's a lie
type User = { name: string };
const u = {} as User; // compiles; u.name is undefined at runtime! strips null and undefined from a typeunknownLikely follow-up: How is as const different from a normal type assertion?
When a check lives in a helper function, TypeScript doesn't look inside it: a helper that returns boolean doesn't narrow anything at the call site.
A type predicate fixes that. Declaring the return type as pet is Fish tells the compiler "if this returns true, treat the argument as Fish", and in the else branch it removes Fish from the union.
An assertion function is declared asserts value is T (or just asserts condition). It either returns normally or throws, so there's no if: everything after the call is narrowed. It's handy for invariants like assertDefined(user) or validating input at a boundary. One quirk: the function must be referenced through an explicitly typed name, so an arrow function in a const needs a type annotation.
The big caveat: the compiler trusts your implementation. A predicate with a wrong body is just a hidden as, so keep guards small and tested, or derive them from a schema.
type Fish = { swim(): void };
type Bird = { fly(): void };
function isFish(pet: Fish | Bird): pet is Fish {
return 'swim' in pet;
}
function assertDefined<T>(v: T): asserts v is NonNullable<T> {
if (v == null) throw new Error('Expected a value');
}
declare const pet: Fish | Bird;
if (isFish(pet)) pet.swim(); else pet.fly();x is T predicate narrows both branches of an ifasserts x is T narrows everything after the callLikely follow-up: Can TypeScript ever infer a type predicate for you?
switch over it exhaustive?midA discriminated (tagged) union is a union of object types that all share one property, the discriminant, with a different literal type in each member, like status: 'loading' | 'success' | 'error'. Checking that property narrows the whole object, so in the 'success' branch TypeScript knows data exists.
It's the idiomatic way to model state because it makes impossible states unrepresentable. Compare it with { loading: boolean; data?: T; error?: string }, which allows loading: true together with an error.
For exhaustiveness, assign the value to a never-typed variable in the default branch. If every case is handled, the value really is never there and it compiles. When someone adds a new member to the union, that line fails to compile and points straight at the switch that needs updating. An assertNever(x: never) helper that throws does the same and also guards at runtime.
type State =
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; error: Error };
function render(s: State): string {
switch (s.status) {
case 'loading': return 'Loading...';
case 'success': return s.data.join(', '); // narrowed: data exists
case 'error': return s.error.message;
default: { const done: never = s; return done; } // breaks if a case is missing
}
}never in default for exhaustivenessLikely follow-up: How would you type the actions of a useReducer with this pattern?
type or interface, destructured in the signature: function Button({ label, onClick }: ButtonProps). React.FC no longer adds children implicitly, so either way declare children?: React.ReactNode when you accept children.ComponentProps<'button'> (or ComponentPropsWithoutRef) so disabled, type and aria-* all work.ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>, FormEvent<HTMLFormElement> from React (not the DOM globals), or type the prop as ChangeEventHandler<HTMLInputElement>. Inline handlers are inferred from context.useState infers from the initial value; pass a type when that value is null or [], like useState<User | null>(null). DOM refs are useRef<HTMLInputElement>(null), and ref.current must be null-checked. A discriminated union of actions types useReducer well.function List<T>(props: ListProps<T>) lets renderItem infer the item type.import { useRef, useState, type ChangeEvent, type ComponentProps } from 'react';
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'ghost' };
function useSearch() {
const [query, setQuery] = useState(''); // string, inferred
const [results, setResults] = useState<string[]>([]); // [] needs a type
const [error, setError] = useState<Error | null>(null);
const inputRef = useRef<HTMLInputElement>(null); // current can be null
const onChange = (e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value);
return { query, results, error, inputRef, onChange };
}children: React.ReactNode explicitlyComponentProps<'button'> to extend native element propsChangeEvent<HTMLInputElement>useState when the initial value is null or []Likely follow-up: How would you type a generic Select component whose onChange returns the selected item?
K extends keyof T with an example.midAn unconstrained type parameter can be anything, so inside the function you can't assume it has any members. extends adds a constraint: with T extends { length: number } you may use .length, and callers still get their exact type back (a number[] stays number[]), not just { length: number }. In a constraint, extends means "is assignable to", not class inheritance.
The classic example is getProp<T, K extends keyof T>(obj: T, key: K): T[K]:
K must be one of T's keys, so a typo like 'emial' is a compile error.T[K] is an indexed access type, so getProp(user, 'name') returns exactly string.Constraints can refer to other type parameters, as K refers to T here. Don't confuse a constraint with a default: <T extends string> restricts what's allowed, <T = string> only says what to use when nothing is inferred or passed.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest([1, 2], [3]); // returns number[], not { length: number }
longest(1, 2); // Error: number has no 'length'
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Ada' };
const n = getProp(user, 'name'); // string
getProp(user, 'email'); // Error: not a key of userextends restricts what a type parameter acceptsK extends keyof T limits keys; T[K] gives the value type<T = X>)keyof and typeof type operators do, and what are indexed access types?midkeyof T produces a union of T's property names as literal types: keyof { id: number; name: string } is 'id' | 'name'. With a string index signature you get string | number, because numeric keys are allowed too.typeof x in a type position gives you the type of a value, so you can derive types from existing code instead of duplicating them: type Config = typeof defaultConfig. It's unrelated to the runtime typeof operator, which returns a string; TypeScript tells them apart by position.T['name'] looks up the type of a property. Indexing with a union gives a union, T[keyof T] gives all value types, and T[number] gives the element type of an array or tuple.They're often combined: keyof typeof obj for an object's keys, and (typeof ROLES)[number] to turn an as const array into a union, so one runtime list is the single source of truth.
keyof T is the union of property namestypeof value lifts a value's type into type spaceT[K] looks up property types; T[number] gets elementskeyof typeof obj derives keys from a runtime objectstrict compiler option enable, and why do strictNullChecks and noImplicitAny matter so much?easystrict is an umbrella flag that turns on a whole family of checks. The two that matter most:
strictNullChecks: null and undefined become their own types instead of being allowed everywhere. Without it, let s: string = null compiles and reading a property of a possibly missing value isn't flagged, which is the classic runtime crash. With it, you have to handle absence with narrowing, ?. or ??.noImplicitAny: an error whenever TypeScript can't infer a type and would silently fall back to any, such as an unannotated parameter.The family also includes strictFunctionTypes, strictPropertyInitialization (class fields must be initialized), strictBindCallApply, noImplicitThis, useUnknownInCatchVariables and alwaysStrict.
Every new project should use strict, and newer compiler versions enable it by default. Checks added to the family later are switched on automatically, so upgrades can surface new errors. When migrating an old codebase, turn the flags on one at a time.
strict is an umbrella for several stricter checksstrictNullChecks: null and undefined must be handlednoImplicitAny: no silent fallback to anyname?: string and name: string | undefined?easyname?: string means the key may be missing entirely; reading it gives string | undefined. name: string | undefined means the key must be present, but its value may be undefined: {} isn't assignable, { name: undefined } is.
Parameters work the same way. f(x?: number) can be called as f(), while f(x: number | undefined) forces callers to write f(undefined) explicitly, which is sometimes exactly what you want.
By default an optional property also accepts an explicit undefined. With exactOptionalPropertyTypes enabled, name?: string means "absent or a string": writing { name: undefined } becomes an error unless you declare name?: string | undefined. The distinction matters at runtime because 'name' in obj, Object.keys and object spread all treat a missing key differently from a key holding undefined.
? means the key may be absent| undefined means present, but possibly undefinedexactOptionalPropertyTypes rejects explicit undefined for ?undefined value differ at runtimeTypeScript infers types from initializers (let count = 0 is number), from return statements, and from context: a callback passed to items.map or an onClick prop gets its parameter types from where it's used. A const holding a literal keeps the literal type (const mode = 'dark' is 'dark'), while let widens it to string.
Where I still annotate:
const ids: string[] = [], useState<User | null>(null).satisfies, to get excess property checks.Everywhere else I let inference work. Redundant annotations are noise, and can even lose information, like const x: string = 'a' dropping the literal type.
const keeps literal types; let widens themnull initial valuesvoid mean as a return type, and how is it different from undefined and never?easyvoid says "this function's return value isn't meant to be used". A function with no return, or a bare return;, is inferred as void.
never is different: the function never returns normally at all, because it always throws or loops forever. undefined as a return type is stricter than void: callers can rely on the value actually being undefined.
The subtle part is function types. A type like () => void accepts functions that do return something; the result is simply treated as unusable. That's deliberate, so nums.forEach((n) => list.push(n)) type-checks even though push returns a number. But when you annotate a function declaration itself with : void, returning a value is an error.
So: void for callbacks and side-effecting functions, never for functions that can't return, and undefined only when callers should depend on getting undefined.
function log(msg: string): void { console.log(msg); }
function crash(): never { throw new Error('boom'); }
type Callback = () => void;
const cb: Callback = () => 42; // OK: the return value is ignored
const result = cb(); // result is void, not number
const list: number[] = [];
[1, 2].forEach((n) => list.push(n)); // push returns number; still fine
function bad(): void { return 42; } // Error: not assignable to voidvoid: return value should be ignorednever: function never returns normallyundefined return type is a stronger promise() => void function types accept value-returning functions: void can't return a valuePartial or Readonly yourself.hardA mapped type builds an object type by iterating over a union of keys: { [K in keyof T]: T[K] } copies T property by property. From there you transform each property:
? or readonly sets them, -? and -readonly remove them. So Partial<T> is { [K in keyof T]?: T[K] }, Required<T> uses -?, and Readonly<T> prefixes readonly.{ [K in keyof T]: boolean } gives a "touched" flag per form field.as: rename keys, for example with template literal types to generate getName-style getters, or map a key to never to drop it, which is how you filter properties by their value type.Mapping over keyof T of a generic T is called homomorphic: it preserves the original readonly and ? modifiers, and arrays and tuples stay arrays and tuples. Mapped types are shallow unless you recurse.
type MyPartial<T> = { [K in keyof T]?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };
type User = { id: number; name: string };
type G = Getters<User>; // { getId: () => number; getName: () => string }
type S = OnlyStrings<User>; // { name: string }[K in keyof T] iterates keys to build a new type?/readonly add modifiers; -?/-readonly remove themas remaps keys; mapping to never drops a keyLikely follow-up: How would you write a type that keeps only the function-valued properties of an object?
infer keyword do?hardA conditional type T extends U ? X : Y is a type-level ternary: if T is assignable to U the result is X, otherwise Y. While T is still an unresolved type parameter the conditional stays deferred, and it's evaluated once T is known. NonNullable<T> is essentially T extends null | undefined ? never : T.
infer declares a type variable inside the extends clause that TypeScript fills in by pattern matching:
T extends Promise<infer U> ? U : T unwraps a promise.T extends (...args: any[]) => infer R ? R : never is how ReturnType works.T extends readonly (infer E)[] ? E : never extracts an array's element type.infer is only allowed in the extends clause of a conditional type, and you can constrain it, as in infer K extends string. Two things to mention: when T is a naked type parameter, conditionals distribute over unions, and a generic function whose return type is a conditional usually needs an assertion in its body, because TypeScript won't narrow T from a runtime check.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hi'>; // true
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type B = UnwrapPromise<Promise<number>>; // number
type C = UnwrapPromise<string>; // string
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type D = MyReturnType<() => Date>; // Date
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type E = ElementOf<string[]>; // stringT extends U ? X : Y is a type-level ternaryT is an unresolved type parameterinfer captures a type by pattern matchingReturnType and promise unwrapping are built this wayT is nakedLikely follow-up: How would you get the type of the first element of a tuple?
as const do, and when is it useful?midas const is a const assertion: it asks TypeScript to infer the narrowest type for a literal expression.
'GET' rather than string).readonly, recursively for nested literals.readonly tuples: ['a', 'b'] as const is readonly ['a', 'b'], not string[].Typical uses: defining a list of values once and deriving a union from it with (typeof ROLES)[number]; lookup tables and config objects whose literal values should be preserved; returning a tuple from a custom hook (return [value, toggle] as const) so destructuring keeps each position's type; and passing object properties to functions that expect a literal union.
It's compile-time only: it doesn't freeze anything at runtime (that's Object.freeze). It can only be applied to literal expressions, not to an arbitrary variable, and it combines nicely with satisfies to validate the shape while keeping the literals.
readonly, recursivelyreadonly tuples(typeof ROLES)[number]satisfies operator do, and how is it different from a type annotation or as?midexpr satisfies Type checks that the expression is assignable to Type without changing its inferred type.
const x: Type = ...) also checks, but the variable's type becomes Type, so you lose the specifics: with Record<string, Route> you no longer know which keys exist, and with a union-typed property you have to narrow it again on every use.as Type barely checks at all (only that the types overlap) and also replaces the type, so it can hide mistakes.satisfies gives you the full check, including missing keys and excess property errors, while keeping the precise inferred type for autocomplete and narrowing.It shines for config objects, route tables, theme palettes and lookup maps. Combining it with a const assertion, { ... } as const satisfies Config, validates the shape and keeps readonly literal values.
type Color = string | [number, number, number];
type Palette = Record<'red' | 'green', Color>;
const a: Palette = { red: [255, 0, 0], green: '#0f0' };
a.green.toUpperCase(); // Error: could be a tuple
const b = { red: [255, 0, 0], green: '#0f0' } satisfies Palette;
b.green.toUpperCase(); // OK: still known to be a string
b.red.map((c) => c / 255); // OK: still an array
const c = { red: '#f00', blue: '#00f' } satisfies Palette; // Error: 'blue' not allowedas skips most checking and can hide mistakesas constA literal type is a single specific value used as a type: 'GET', 42, true. On its own that's rarely useful, but unions of literals model a fixed set of options: type Method = 'GET' | 'POST', type Dice = 1 | 2 | 3 | 4 | 5 | 6. (boolean itself is just true | false.)
Widening is how inference decides between the literal and its base type. const x = 'GET' can never change, so its type is 'GET'. let x = 'GET' can be reassigned, so it widens to string. Object properties and array elements are mutable too, so const req = { method: 'GET' } infers method: string, even though req is a const.
That's why passing req.method to a function that expects 'GET' | 'POST' fails, a very common surprise. The fixes: annotate the object with the right type, use as const, or use satisfies.
const keeps the literal; let widens to the base typeas const or satisfiesAn array type T[] has any length and one element type. A tuple has a known length (or a known structure) and a type per position: [string, number]. Tuples model positional results, like React's useState returning [state, setState] or Object.entries returning [string, T][].
Tuple features:
[x: number, y: number], which only improve readability and editor hints.[string, number?] and rest elements [string, ...number[]].readonly [number, number] to forbid mutation, and T['length'] is a literal type such as 2.Gotchas: a tuple is a plain array at runtime, and a mutable tuple still has push, which TypeScript won't track, so prefer readonly tuples. Also, an array literal like [1, 'a'] infers (string | number)[], not a tuple, unless there's a contextual tuple type, a return type annotation, or as const.
as const or annotations produce tuplespush; prefer readonlyLikely follow-up: What are variadic tuple types used for?
Overloads give one function several call signatures. You write the overload signatures without bodies, followed by a single implementation signature that is compatible with all of them. Callers only see the overloads, never the implementation signature, and TypeScript picks the first overload that matches from top to bottom, so order them from most specific to least.
They're useful when the return type depends on the argument types in a way a union can't express: parse(input: string): Row but parse(input: string[]): Row[]. The DOM's createElement and addEventListener typings use this idea too.
The costs: the implementation is only checked against its own broad signature, so it can return the wrong thing for a particular overload without an error. And a caller holding a string | number can't call it unless some overload accepts the union.
So I reach for a union parameter first, then a generic, and use overloads only when the input-to-output mapping really needs them.
public, private, protected and readonly in classes. How is TypeScript's private different from a #private field?easypublic is the default: accessible anywhere.protected: accessible inside the class and its subclasses.private: accessible only inside the declaring class.readonly: can only be assigned in its declaration or in the constructor.Parameter properties like constructor(private readonly repo: Repo) declare and assign a field in one step.
The key difference: TypeScript's private is compile-time only. After the types are erased it's an ordinary property, so it's reachable with bracket notation (obj['secret'] even type-checks) or a cast, and it shows up in JSON.stringify and Object.keys. A #secret field is a JavaScript feature enforced by the runtime: truly inaccessible from outside the class body, not enumerable, and invisible even to subclasses.
Use # when privacy must really hold, for example in a library. TypeScript's modifiers are fine for documenting intent within an app, and protected has no # equivalent.
public default; protected includes subclasses; private class onlyreadonly assignable only at declaration or in constructorprivate is erased: reachable at runtime#private is enforced by the JavaScript runtimeAn abstract class can't be instantiated directly (new Shape() is an error). It's a base class that can hold real implementation, such as fields, a constructor and concrete methods, plus abstract members that every subclass must implement. An interface is purely a type contract: no implementation, and nothing left at runtime.
I'd pick an abstract class when:
describe() calls an abstract area().instanceof checks or as a dependency-injection token (Angular uses abstract classes this way).I'd pick an interface when I only need a contract. A class can implements many interfaces but extends only one class, and plain objects can satisfy an interface without any class at all.
They combine well: an interface for the public contract and an abstract class as an optional partial implementation.
newabstract membersinstanceof, DI tokensextends, but many implements.d.ts declaration file, and what is DefinitelyTyped?easyA .d.ts file contains only type information: signatures, interfaces and declare statements, with no implementation. It describes JavaScript that exists somewhere else, so TypeScript can type-check code that uses it. When you publish a library, declaration: true makes the compiler generate .d.ts files next to the compiled JavaScript so consumers get types.
For JavaScript packages that don't ship their own types, the community maintains them in the DefinitelyTyped repository, published to npm under the @types scope: @types/lodash, @types/node, @types/react. When you import such a package, the compiler finds its @types package; which global type packages (like Node or test-runner globals) are loaded is controlled by the types compiler option.
You write your own declarations for untyped modules, for globals injected by a bundler or a script tag, and for non-code imports like *.svg or *.css. skipLibCheck skips checking .d.ts files, which speeds up builds.
declaration: true for libraries@types/*readonly do, and how does it differ from const and Object.freeze?easyconst is JavaScript: the variable binding can't be reassigned, but the object it points to can still be mutated.
readonly is a TypeScript property modifier: the property can't be reassigned after initialization. readonly string[] (or ReadonlyArray<string>) removes mutating methods like push and splice from the type, and Readonly<T> marks every property readonly.
Caveats worth mentioning:
readonly properties is still assignable to a type with the same properties writable, so it's not a hard guarantee. Readonly arrays are stricter: readonly number[] isn't assignable to number[].Object.freeze is the runtime counterpart (also shallow), and TypeScript types its result as Readonly. I use readonly on props, state and array parameters I don't intend to mutate.
const protects the binding, not the objectreadonly blocks property reassignment at compile timereadonly T[] removes mutating array methodsObject.freeze enforces (shallowly) at runtimeExclude and Omit? And what do Extract and NonNullable do?midThey work on different kinds of types:
Exclude<U, X> works on a union: it removes the members assignable to X. Exclude<'a' | 'b' | 'c', 'a'> is 'b' | 'c'.Omit<T, K> works on an object type: it removes properties. Omit<User, 'password'> is User without password. It's built from the other one: Pick<T, Exclude<keyof T, K>>.Extract<U, X> is the opposite of Exclude: it keeps the members assignable to X, which is handy for pulling one member out of a discriminated union.NonNullable<T> removes null and undefined.Two Omit gotchas worth knowing. Its keys aren't checked against T, so a typo like Omit<User, 'pasword'> compiles silently, while Pick would reject it. And it isn't distributive: on a union of object types it keeps only the common keys, so you need T extends unknown ? Omit<T, K> : never to omit from each member.
type Status = 'idle' | 'loading' | 'success' | 'error';
type Busy = Exclude<Status, 'idle'>; // 'loading' | 'success' | 'error'
type Done = Extract<Status, 'success' | 'error'>; // 'success' | 'error'
type Name = NonNullable<string | null | undefined>; // string
interface User { id: number; name: string; password: string }
type PublicUser = Omit<User, 'password'>; // { id: number; name: string }
type Typo = Omit<User, 'pasword'>; // compiles! keys aren't checked
type Picked = Pick<User, 'pasword'>; // Error: Pick checks its keys
type Shape = { kind: 'circle'; r: number } | { kind: 'square'; size: number };
type Circle = Extract<Shape, { kind: 'circle' }>; // { kind: 'circle'; r: number }Exclude filters union members; Omit removes object propertiesOmit is Pick plus Exclude on keyof TExtract keeps matching members, e.g. one union variantOmit doesn't check keys; Pick doesOmit on a union collapses it to common keysReturnType, Parameters and Awaited help you derive types from existing code?midThey derive types from functions and promises instead of duplicating definitions, so the types stay in sync when the function changes.
ReturnType<typeof fn> gives the return type. You need typeof because these utilities take a type, and fn is a value.Parameters<typeof fn> gives the parameter list as a tuple, so Parameters<typeof fn>[0] is the first argument's type.Awaited<T> unwraps promises recursively: Awaited<Promise<Promise<number>>> is number. Awaited<ReturnType<typeof fetchUser>> is the resolved value of an async function.ConstructorParameters and InstanceType.Real uses: typing the result of a library function whose types aren't exported, Redux's RootState = ReturnType<typeof store.getState>, and wrappers like (...args: Parameters<typeof fn>) => ....
One caveat: on an overloaded function, ReturnType and Parameters only see the last overload signature.
typeof fn: utilities take types, not valuesParameters returns a tuple; index it for one argumentAwaited unwraps nested promises recursivelyx instanceof User when User is an interface?easyNo. Types are erased during compilation: interfaces, type aliases, annotations, generics and assertions all disappear, and what runs is plain JavaScript.
So an interface isn't a value. x instanceof User doesn't compile ("only refers to a type, but is being used as a value here"), and there's no way to list an interface's keys at runtime. Generics are erased too, so inside a generic function you can't write new T() or check what T is.
What does exist at runtime are the features that emit code: classes (so instanceof works with them), enums, and namespaces that contain values.
The practical consequence: a function typed (user: User) can still receive garbage from an API, localStorage or JSON.parse. Checks have to be written in real JavaScript: typeof, in, Array.isArray, custom type guards, or a schema library like zod that validates at runtime and gives you the static type from the same schema.
instanceof, no runtime keysLikely follow-up: How would you check at runtime that an unknown value is a User?
function add(a: number, b: number): number.b?: number must come after required ones. Default parameters b = 10 get their type from the default and are optional for callers....nums: number[], or a tuple type for a fixed structure.type Handler = (event: MouseEvent) => void. When a function also has properties, use a call signature in an object type: { (x: number): string; displayName: string }.const h: Handler = (e) => ... needs no annotation on e.this parameter, function (this: HTMLElement), which is erased from the output.Also, a function with fewer parameters is assignable to a function type with more, which is why arr.map((x) => ...) works without accepting index and array.
noUncheckedIndexedAccess option change?midAn index signature types an object used as a dictionary whose keys aren't known in advance: { [name: string]: number }, which is equivalent to Record<string, number>. Keys can be string, number, symbol or template literal patterns such as data-${string}, and any named properties must be compatible with the signature's value type.
The catch: by default, scores['bob'] is typed number even though the key may not exist, so you get undefined at runtime with no warning. The same applies to arrays: list[10] is typed number.
noUncheckedIndexedAccess adds | undefined to every index access, forcing a check before use. It isn't part of strict, so you opt in explicitly. It's a bit noisy with index-based loops, which is a good excuse for for...of.
Alternatives: a Map, whose get already returns V | undefined. And note Record<'light' | 'dark', string> with literal keys is not an index signature: it's a fixed set of required properties.
type Scores = { [name: string]: number }; // same as Record<string, number>
const scores: Scores = { ada: 10 };
const bob = scores['bob']; // number by default, but undefined at runtime!
bob.toFixed(); // crashes; flagged under noUncheckedIndexedAccess
const list = [1, 2, 3];
const tenth = list[10]; // number (number | undefined with the flag)
type Theme = Record<'light' | 'dark', string>; // both keys required
const theme: Theme = { light: '#fff' }; // Error: 'dark' is missing{ [key: string]: T } types dictionary-like objectsRecord<string, T>noUncheckedIndexedAccess adds | undefined; not in strictRecord with literal keys means required propertiestsconfig.json options do you consider essential, and what do they control?midThe ones I always look at:
strict, ideally plus noUncheckedIndexedAccess.target: which JavaScript syntax the compiler emits. lib: which built-in type declarations exist, like DOM or ES2022 (derived from target if you don't set it).module and moduleResolution: how imports are emitted and resolved. bundler suits Vite or webpack apps; nodenext suits code run by Node, and respects package.json exports and type.noEmit when a bundler or esbuild/SWC does the transpiling and tsc only type-checks. Then isolatedModules or verbatimModuleSyntax ensures each file can be compiled on its own.jsx: react-jsx for React.paths for import aliases. It only affects type-checking and resolution, the bundler needs the same aliases.skipLibCheck for faster builds; declaration, outDir and sourceMap for libraries.include/exclude, extends for shared base configs, and references for project references in monorepos.strict on, plus noUncheckedIndexedAccess if possibletarget controls emitted syntax; lib the available typesmodule/moduleResolution: bundler or nodenextnoEmit plus isolatedModules when a bundler transpilespaths doesn't rewrite imports; configure the bundler tooconst user = (await res.json()) as User good enough?midNot really. res.json() returns Promise<any>, and the as only tells the compiler to trust you. If the backend changes a field, returns an error payload, sends null or sends dates as strings, TypeScript can't know, and the crash happens later, far from the fetch. Types are erased, so the only way to know the shape is to validate at runtime, at the boundary.
Options, from lightest to strongest:
unknown and narrow it with hand-written type guards. Fine for small shapes.parse (or safeParse) at the boundary, and derive the static type with z.infer, so the type and the validation can't drift apart.A generic fetchJson<T>(url): Promise<T> helper is just a cast in disguise unless it also takes a validator.
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().nullable() });
type User = z.infer<typeof UserSchema>; // { id: number; name: string; email: string | null }
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // throws if the shape is wrong
}res.json() is any; as User checks nothingunknown and narrow, or use a schemaparse at runtime, z.infer for the typefetchJson<T> without validation is a hidden castWhen a conditional type checks a naked type parameter, as in T extends U ? X : Y, and T is instantiated with a union, the conditional is applied to each member separately and the results are unioned. So with ToArray<T> = T extends unknown ? T[] : never, ToArray<string | number> is string[] | number[], not (string | number)[]. That's exactly what makes Exclude<T, U> = T extends U ? never : T work: members that map to never vanish from the union.
To turn distribution off, wrap both sides in a one-element tuple: [T] extends [U] ? X : Y. Now the union is checked as a whole.
Gotchas:
never is the empty union, so a distributive conditional given never returns never without evaluating either branch. A correct IsNever needs [T] extends [never].boolean is true | false, so it distributes too.extends check.type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayNonDist<string | number>; // (string | number)[]
type MyExclude<T, U> = T extends U ? never : T;
type C = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
type IsNeverWrong<T> = T extends never ? true : false;
type IsNever<T> = [T] extends [never] ? true : false;
type D = [IsNeverWrong<never>, IsNever<never>]; // [never, true]Exclude, Extract and NonNullable[T] extends [U] to disable distributionnever input yields never; boolean distributes tooTemplate literal types use template string syntax at the type level to build string literal types. When a placeholder holds a union, the result contains every combination: two sizes times two colors gives four class names. Placeholders can also be string or number to describe a pattern, like #${string} for hex colors or ${number}px for pixel values. The intrinsic helpers Uppercase, Lowercase, Capitalize and Uncapitalize transform them.
Practical uses:
'click' to onClick with key remapping in a mapped type.infer, for example extracting id from a route like '/users/:id' to type route params.The caveat is size: combinations multiply quickly, and big cross products slow the checker down; past a certain union size the compiler gives up with an error.
type Size = 'sm' | 'lg';
type Color = 'red' | 'blue';
type ClassName = `${Size}-${Color}`; // 'sm-red' | 'sm-blue' | 'lg-red' | 'lg-blue'
type EventName = 'click' | 'focus';
type Handlers = { [E in EventName as `on${Capitalize<E>}`]: () => void }; // onClick, onFocus
type Pixels = `${number}px`;
const w: Pixels = '12px';
const h: Pixels = '12em'; // Error: doesn't match the pattern
type Param<T> = T extends `${string}:${infer P}` ? P : never;
type Id = Param<'/users/:id'>; // 'id'string/number placeholders describe string patternsinfer to parse strings, as to rename keysDeclaration merging means the compiler combines several declarations with the same name into one. Interfaces merge their members; namespaces merge with other namespaces and with classes, functions and enums (for example, to attach static properties to a function); enums merge with each other. Type aliases never merge.
Module augmentation uses this to extend types you don't own. In a file that is itself a module (it has an import or export), you write declare module 'library-name' { ... } and re-declare the library's interface with extra members, which merge into the original. Everyday examples: adding requiresAuth to Vue Router's RouteMeta, adding user to Express's request type, or typing Vite's ImportMetaEnv. For globals, declare global { interface Window { ... } } does the same thing.
The limits: you can only add to or merge with existing declarations, not replace them or add new top-level declarations to the module, and a repeated property must keep the same type.
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean; // merged into the library's interface
}
}
declare global {
interface Window { dataLayer: unknown[] }
}
window.dataLayer.push({ event: 'signup' }); // now typeddeclare module 'lib' re-opens a library's interfacesdeclare global extends globals like WindowStructural typing normally allows extra properties. The exception is a fresh object literal, one written directly where a type is expected: TypeScript reports any property the target type doesn't declare. draw({ colour: 'red' }) fails for an Options type with color?, which catches typos that would otherwise pass silently because every property is optional.
Once the literal is stored in an unannotated variable and passed later, it's no longer fresh, so ordinary structural rules apply and extra properties are fine. That's why the "same" object can compile through a variable and fail inline. It's a targeted heuristic, not exact types, which TypeScript doesn't have.
A related rule is weak type detection: if every property of the target is optional, an object sharing no properties with it is rejected even through a variable.
To pass extra properties on purpose you can annotate the variable, add an index signature to the target, or spread the object, but usually the error is pointing at a real bug.
interface Options { color?: string; width?: number }
function draw(opts: Options) {}
draw({ colour: 'red' }); // Error: 'colour' does not exist in type 'Options'
const o = { colour: 'red', width: 2 };
draw(o); // OK: not fresh, and it shares 'width'
const typo = { colour: 'red' };
draw(typo); // Error: no properties in common (weak type)object, Object and {}?mid{} does not mean "empty object". It means any value except null and undefined: strings, numbers and booleans all qualify, because property access works on them. In fact unknown behaves like {} | null | undefined.Object (capital O) is the type of Object.prototype's members. It behaves almost the same as {}, primitives included, and linters flag it as a mistake.object (lowercase) means any non-primitive: plain objects, arrays, functions, class instances. It rejects string, number, boolean, symbol, bigint, null and undefined.What to use instead:
Record<string, never>.Record<string, unknown> or object.T extends {}.The mistake interviewers look for is writing {} or Object to mean "an object" and then being surprised that 'hello' is accepted.
{} accepts any non-nullish value, including primitivesObject behaves like {}; avoid itobject means non-primitive values onlyunknown behaves like {} | null | undefinedRecord<string, never> for a truly empty objectcatch block, and how should you handle it?midJavaScript can throw anything, not just Error instances: strings, numbers, plain objects, even undefined. So TypeScript can't know what you'll catch. It used to be any; with useUnknownInCatchVariables, which is part of strict, the catch variable is unknown and must be narrowed before use.
You can't annotate it with a specific type: catch (e: Error) is a compile error, only any or unknown are allowed, because TypeScript can't guarantee what was thrown.
The idioms:
if (err instanceof Error) to access message and stack, with a fallback like String(err) for anything else.getErrorMessage(err: unknown) helper used everywhere.instanceof for specific handling.Watch out for promises: the reason in .catch((reason) => ...) is typed any by the standard library, so annotate it as unknown yourself.
Errorunknown under strict (useUnknownInCatchVariables)any or unknown annotations are allowedinstanceof Error and a fallback.catch reason is any; annotate it unknownimport type, and why do options like isolatedModules and verbatimModuleSyntax care about it?midimport type { User } from './user' imports only type information. It's always removed from the output and can't be used as a value. The inline form, import { type User, createUser } from './user', marks individual names.
Why it matters: tsc knows which imports are only used as types and drops them. But tools that transpile one file at a time (esbuild, SWC, Babel, and the bundlers built on them) can't look into ./user to see whether User is a type or a value. They might keep an import of something that doesn't exist at runtime, or drop an import that was needed for its side effects.
isolatedModules makes tsc report code that single-file transpilers can't handle safely, such as re-exporting a type without export type.verbatimModuleSyntax makes the rule simple and predictable: imports without type are kept exactly as written, imports with type are dropped. So every type-only import must be marked.The result is output you can predict from the source, whatever tool compiles it.
import type is always erased from the outputimport { type User, createUser }isolatedModules flags unsafe patterns like type re-exportsverbatimModuleSyntax: unmarked imports are kept as written// @ts-ignore and // @ts-expect-error, and when is either acceptable?easyBoth comments suppress type errors on the next line.
@ts-ignore suppresses silently, whether or not there is an error.@ts-expect-error suppresses too, but is itself reported as an error ("Unused '@ts-expect-error' directive") when the next line has no error.That makes @ts-expect-error self-cleaning: when a library update or refactor fixes the underlying problem, the compiler tells you to remove the comment instead of leaving a stale suppression that might hide a future bug. Add a reason after it, like a link to the upstream issue.
Acceptable uses are narrow: tests that deliberately pass invalid input, or working around a known bug in third-party types. Both are blunt instruments that hide every error on that line, not just the one you meant. Related directives: // @ts-nocheck disables checking for a whole file (handy mid-migration) and // @ts-check enables checking in a JavaScript file. Normally, fix the types or narrow instead.
@ts-expect-error errors if nothing needs suppressing@ts-expect-error: it can't go staleIncrementally, never as a big-bang rewrite.
tsconfig.json with allowJs so JavaScript and TypeScript compile together, and noEmit if the bundler does the transpiling. Add a type-check step to CI early.checkJs or a per-file // @ts-check plus JSDoc types gives checking in plain .js files.@types/* packages and write small .d.ts shims for untyped ones.noImplicitAny, then strictNullChecks, then full strict, fixing errors per directory.any, and track the remaining count of any and @ts-expect-error as a metric.Prioritize boundaries, such as API responses, component props and shared domain types, because that's where types pay off most. Temporary any is fine as long as it's visible and shrinking.
allowJs, JS and TS side by sidecheckJs and JSDoc for checking before renamingany count prevent regressionsA default type parameter works like a default function argument: with interface ApiResponse<T = unknown>, writing plain ApiResponse means ApiResponse<unknown>. The rules mirror optional parameters: defaulted type parameters come after required ones, a default must satisfy the parameter's constraint, and it can refer to earlier parameters, as in <A, B = A>.
With function calls, inference wins: a default is only used when there's nothing to infer from.
The subtle part is explicit type arguments, which are all-or-nothing. TypeScript has no partial inference. If you write pair<number>(1, 'x') for pair<A, B = A>, B isn't inferred from 'x': it takes its default, number, and the call fails. And for a function with two parameters and no defaults, passing only one type argument is an error ("Expected 2 type arguments").
Defaults are common in library types, such as event emitters with a default event map or generic components with a default item type.
<T = Default> used when no type argument is givenBecause TypeScript is structural, type UserId = string is only an alias: any string, including an order id, can be passed where a UserId is expected.
A brand adds a property that exists only in the type system: type UserId = string & { readonly __brand: 'UserId' }. A plain string no longer satisfies it, and UserId and OrderId aren't interchangeable. At runtime it's still an ordinary string with zero overhead, and a UserId can still be used anywhere a string is expected.
Branded values are created in one place, a "smart constructor" or validation function, which is the only spot that needs an as. From then on the type proves the value went through that check.
Good uses: IDs, validated values (Email, NonEmptyString), units (Meters vs Feet) and sanitized HTML. Some teams use a unique symbol as the brand key so it can't clash, and schema libraries support it directly, such as zod's .brand().
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function toUserId(raw: string): UserId {
if (!raw.startsWith('u_')) throw new Error('Invalid user id');
return raw as UserId; // the only assertion, behind validation
}
function getUser(id: UserId) {}
getUser(toUserId('u_42')); // OK
getUser('u_42'); // Error: a plain string isn't a UserId
getUser('o_1' as OrderId); // Error: an OrderId isn't a UserIdVariance describes how subtyping between two types carries over to types built from them. Say Dog is a subtype of Animal:
() => Dog is assignable to () => Animal.(a: Animal) => void is assignable to (d: Dog) => void, because a function that handles any animal can handle a dog, but not the other way round. strictFunctionTypes, part of strict, enforces this for function types.handle(a: Animal): void, are still checked in both directions, mainly so generic types like Array<T> keep relating covariantly. That's unsound, so declare callbacks with property syntax, handle: (a: Animal) => void, to get strict checking.Arrays are another deliberate hole: Dog[] is assignable to Animal[] even though they're mutable, so you could push a Cat through the Animal[] alias. You can document variance explicitly with in and out modifiers on type parameters, such as interface Producer<out T>.
class Animal { name = '' }
class Dog extends Animal { bark() {} }
let handleAnimal = (a: Animal) => {};
let handleDog = (d: Dog) => d.bark();
handleDog = handleAnimal; // OK: parameters are contravariant
handleAnimal = handleDog; // Error under strictFunctionTypes
interface MethodStyle { handle(a: Animal): void }
interface PropStyle { handle: (a: Animal) => void }
const m: MethodStyle = { handle: (d: Dog) => d.bark() }; // OK: method params are bivariant
const p: PropStyle = { handle: (d: Dog) => d.bark() }; // Error: checked strictlystrictFunctionTypesDeepPartial?hardA type alias or interface can refer to itself, as long as the reference sits inside a structure such as an object property, an array or a generic, rather than being an immediate cycle. Common examples:
interface TreeNode { value: string; children: TreeNode[] }.Json union where arrays and objects contain Json again.DeepPartial or DeepReadonly, which apply themselves to each nested property.Recursive conditional types work too, for example flattening nested arrays or parsing strings with template literal types.
Things to get right:
Date, Map and so on. A naive DeepPartial maps over a function's properties instead of leaving it alone.type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type DeepPartial<T> = T extends (...args: any[]) => unknown
? T
: T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
interface Settings { theme: { mode: 'light' | 'dark'; accent: string }; tags: string[] }
const patch: DeepPartial<Settings> = { theme: { mode: 'dark' } }; // nested fields optional
const ok: Json = { user: { name: 'Ada', tags: ['x'] } };
const bad: Json = { when: new Date() }; // Error: a Date isn't JSONDeepPartial/DeepReadonly recurse via mapped typesDate and arrays deliberatelyA decorator is a function applied with @name to a class or a class member (method, accessor, field) that can observe, wrap or replace it. Typical uses are logging or timing methods, validation, registering routes or components, and dependency injection.
There are two incompatible flavors:
context object with its kind, name and helpers like addInitializer. They can't decorate parameters.experimentalDecorators, have a different signature (target, property key, descriptor), support parameter decorators, and together with emitDecoratorMetadata emit runtime type metadata for dependency injection. Angular, NestJS and TypeORM were built on this flavor.Cautions: a library's decorators only work in the mode they were written for; decorators emit runtime code, so they aren't erasable syntax; and they only apply to classes and class members, not standalone functions. Outside a framework that expects them, a plain higher-order function is usually simpler.
@ to classes and class memberscontextexperimentalDecorators is the legacy, incompatible flavorLikely follow-up: How would you write a decorator that logs how long a method takes?
Object.keys(obj) return string[] instead of an array of obj's keys?hardBecause of structural typing, an object can have more properties than its type declares. A value typed { name: string } might really be { name: 'Ada', password: '...' }, assigned from a variable with extra fields. If Object.keys returned 'name'[], that would be a lie, so string[] is the honest type. for...in gives string keys for the same reason.
The consequence is that obj[key] with key: string fails under strict ("expression of type 'string' can't be used to index type ..."), because the object type has no index signature.
Options:
Object.keys(obj) as Array<keyof typeof obj>, ideally inside a small keysOf helper so the assumption lives in one place.as const array of the keys you care about.Map or a Record<K, V> with a known key union when the data really is a dictionary.const user = { name: 'Ada', role: 'admin' };
for (const key of Object.keys(user)) {
user[key]; // Error: 'string' can't be used to index this type
}
const keys = Object.keys(user) as Array<keyof typeof user>; // fine: we built user
keys.forEach((k) => user[k]); // k: 'name' | 'role'
type Named = { name: string };
const extra = { name: 'Ada', password: 'hunter2' };
const n: Named = extra; // legal, so Object.keys(n) includes 'password'Object.keys honestly returns string[]string fails without an index signatureArray<keyof T> only when the shape is exactMap or a Recordon('login', handler) knows the payload type of each event?hardDescribe the events as a map from event name to payload type, and make the emitter generic over that map:
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void)emit<K extends keyof E>(event: K, payload: E[K])When you call bus.on('login', ...), K is inferred as the literal 'login' and the indexed access E[K] looks up its payload type. A misspelled event name or a wrong payload is a compile error, and the handler's parameter is inferred without an annotation.
Internally the handler storage is a mapped type, { [K in keyof E]?: Array<(payload: E[K]) => void> }, so each list is typed per event.
Refinements worth mentioning: events without a payload can use a rest-tuple parameter, ...args: E[K] extends void ? [] : [E[K]], so emit('logout') needs no second argument; off and once follow the same pattern; and in more complex implementations a small internal cast is acceptable as long as the public API stays strict.
type Events = { login: { userId: string }; error: Error };
class Emitter<E> {
private handlers: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {};
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof E>(event: K, payload: E[K]) { this.handlers[event]?.forEach((h) => h(payload)); }
}
const bus = new Emitter<Events>();
bus.on('login', (p) => console.log(p.userId)); // p: { userId: string }
bus.emit('login', { id: 1 }); // Error: payload must be { userId: string }
bus.on('logn', () => {}); // Error: not an event nameK extends keyof E infers the event name literalE[K] looks up the payload for that eventVariadic tuple types allow generic spreads inside tuple types, as in [...T, ...U] where T and U are tuple type parameters. That lets you type operations that preserve the exact length and element types of tuples instead of collapsing them into arrays:
concat(a, b) that returns [...T, ...U].bind, and wrappers or middleware that forward ...args to another function while keeping its parameter types.Writing a parameter as [...T] rather than T hints the compiler to infer a tuple instead of an array. Rest elements can also appear anywhere in a tuple, not only at the end, as in [...string[], number].
This is what makes a type-safe partial(fn, 'Hello') possible: the compiler splits fn's parameter list into the part you supplied and the part the returned function still needs, parameter names included.
function concat<T extends unknown[], U extends unknown[]>(a: [...T], b: [...U]): [...T, ...U] {
return [...a, ...b];
}
const t = concat([1, 'a'], [true]); // [number, string, boolean]
function partial<A extends unknown[], B extends unknown[], R>(
fn: (...args: [...A, ...B]) => R, ...first: A
): (...rest: B) => R {
return (...rest) => fn(...first, ...rest);
}
const greet = (greeting: string, name: string, punctuation: string) => greeting + ', ' + name + punctuation;
const hello = partial(greet, 'Hello'); // (name: string, punctuation: string) => string[...T, ...U][...T] parameter hints tuple inferenceconst modifier on a type parameter do, as in function definePages<const T>(pages: T)?hardIt makes inference for that type parameter behave as if the caller had written as const on the argument: literal types are kept, array literals are inferred as tuples, and object properties keep their literal types.
Without it, definePages(['home', 'about']) infers string[] and the specific names are lost. With <const T extends readonly string[]> it infers readonly ['home', 'about'], so the library can derive a union like T[number] and reject unknown page names elsewhere.
It's aimed at library APIs where literal values matter, such as route definitions, event names, config builders or query builders, so callers get precise types without having to remember as const at every call site.
The limit: it only affects expressions written directly in the call. Passing a variable that was already inferred as string[] still gives string[], because that variable's type was widened when it was declared.
function plain<T extends readonly string[]>(names: T) { return names; }
function literal<const T extends readonly string[]>(names: T) { return names; }
const a = plain(['home', 'about']); // string[]
const b = literal(['home', 'about']); // readonly ['home', 'about']
type Page = (typeof b)[number]; // 'home' | 'about'
const list = ['home', 'about'];
const c = literal(list); // string[]: only call-site literals countas constas constCommon causes:
interface ... extends would do. Interface relationships are cached; intersections are often re-checked.The symptoms are slow tsc runs, laggy autocomplete, and errors like "Type instantiation is excessively deep and possibly infinite".
To diagnose, tsc --extendedDiagnostics reports check time, memory and the number of types and instantiations; --generateTrace writes a trace you can inspect to find the expensive files and types; and --explainFiles shows why each file is included at all.
Fixes: prefer interfaces over intersections, add explicit return types to exported functions, name complex types instead of repeating them inline, shrink unions, cap recursion, enable skipLibCheck, and split large repos with incremental builds and project references.
interface extends over big intersections--extendedDiagnostics and --generateTrace to diagnoseskipLibCheck, incremental, project references at scaleNo questions match that filter.