TypeScript NotesRohit’s interview study guide
Chapter 02

Everyday Types

Inference, primitives and literals, arrays and tuples, object types, and the special types any, unknown, never and void.

15 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Type annotations vs type inference

An annotation is you telling the compiler a type: let count: number = 0. Inference is the compiler working the type out from the value: let count = 0 gets number all by itself. Both produce exactly the same type information; the difference is only who wrote it down.

TypeScript infers far more than people expect. Every variable with an initialiser, every function's return type, every array and object literal gets a type without you saying anything. So the real skill isn't "how do I annotate"; it's knowing where inference can't reach or gives an answer you don't want, and annotating exactly there.

A useful mental model: inference flows from values to names. When there's a value to look at (an initialiser, a return expression), the compiler reads its type and gives it to the name. When there's no value yet (a function parameter, a variable declared empty), there's nothing to read, and you have to supply the type.

TypeScript
function add(a: number, b: number) {
  return a + b;
}

const total = add(2, 3); // const total: number
What’s happening
  1. The parameters a and b are annotated because there is nothing for the compiler to infer them from. TypeScript never guesses a parameter's type from how the body uses it. Without the annotations, strict mode reports TS7006: "Parameter 'a' implicitly has an 'any' type."
  2. There's no return annotation, so the checker looks at every return expression in the body. There is one, a + b, and with two number operands + produces number. The inferred signature is function add(a: number, b: number): number. That's the "automatically inferring return type to number since inputs are both number" from my notes.
  3. At the call add(2, 3) the checker matches 2 and 3 against number and accepts them. The call expression has the function's return type, number.
  4. total has no annotation, so it takes the type of its initialiser: number. (Not the literal 5: the compiler computes types, not values. It never runs 2 + 3.)
  5. If you later changed b to string, the return type would silently become string and every caller would get the new type without any error at add itself. That's the argument for annotating return types on functions other people call (more below).
AdvancedInference with let vs const

Variables get their type from their initialiser, and the details depend on let vs const and on what's in the literal:

TypeScript
let count = 0; // let count: number
const limit = 10; // const limit: 10
const tags = ["a", "b"]; // const tags: string[]
const mixed = [1, "two", null]; // const mixed: (string | number | null)[]

let status: "idle" | "loading" = "idle";
status = "loading";
status = "done"; // ❌ Type '"done"' is not assignable to type '"idle" | "loading"'.
What’s happening
  1. let count = 0: the value 0 has the literal type 0, but because a let can be reassigned, the compiler widens it to number. A let typed as just 0 would be useless.
  2. const limit = 10: a const can never change, so the narrow literal type 10 is kept. (Widening is covered properly in Primitive and literal types.)
  3. tags: an array literal's element type is the best common type of its elements, widened. Both are strings, so string[]. Arrays are mutable, so element literals are widened even under const: the const only stops you reassigning tags, not pushing into it.
  4. mixed: no single element type covers the others, so the element type becomes the union of all of them: (string | number | null)[].
  5. status is annotated because inference would give string (it's a let initialised with a string). The annotation narrows the variable to the two states you actually allow, so assigning "done" fails with TS2322: Type '"done"' is not assignable to type '"idle" | "loading"'. Here the annotation isn't noise; it's the specification.
AdvancedExplicit return types catch errors early

The most valuable annotation in day-to-day code is an explicit return type, because it moves errors to where the bug actually is:

TypeScript
type User = { id: number; name: string };

function findUser(users: User[], id: number): User {
  const user = users.find((u) => u.id === id);
  return user; // ❌ Type 'User | undefined' is not assignable to type 'User'.
}
What’s happening
  1. users.find(...) is declared in the standard library to return User | undefined, because there may be no match. So user is inferred as User | undefined.
  2. The function promises : User. The checker compares the type of the return expression against that promise and reports TS2322: Type 'User | undefined' is not assignable to type 'User', with the detail "Type 'undefined' is not assignable to type 'User'."
  3. Without the : User annotation there'd be no error here at all. The return type would be inferred as User | undefined, and the errors would appear later at each call site that does findUser(...).name, far from the cause.
  4. The annotation also documents intent: a reader sees the contract without reading the body. The fix is to decide what "not found" means: return User | undefined honestly, or throw.
AdvancedRules of thumb for annotations

Rules of thumb:

  • Let inference do local variables. const name: string = "Ada" adds nothing and is one more thing to keep in sync.
  • Always annotate parameters. The compiler can't infer them, except through contextual typing.
  • Annotate return types of exported or public functions. They're a contract, and a contract should be written down. For small private helpers, inference is fine.
  • Annotate when there's no initialiser or it's empty: let timer: NodeJS.Timeout | undefined;, const ids: number[] = [];.
  • Annotate to narrow on purpose, like status above, or use as const / satisfies when you want to keep the literal types inference would otherwise widen.

#Contextual typing

Normal inference goes inside out: look at a value, work out its type. Contextual typing goes outside in: the compiler looks at where an expression sits, sees what type that position expects, and uses that expectation to type the expression. It's why you almost never annotate callback parameters.

Mental model: an expression put into a typed slot takes its shape from the slot, like water poured into a mould. The slots are function arguments whose parameter type is known, variables with a function-type annotation, return statements in functions with a declared return type, object-literal properties with a known target type, and so on.

TypeScript
const names = ["ada", "grace"];
const upper = names.map((name) => name.toUpperCase()); // const upper: string[]
What’s happening
  1. names is inferred as string[] from its initialiser (ordinary inside-out inference).
  2. names.map resolves to Array<string>.map, whose declaration is roughly map<U>(callbackfn: (value: string, index: number, array: string[]) => U): U[].
  3. The arrow function (name) => ... is sitting in the callbackfn slot. Its parameter has no annotation, so the checker takes the type from the slot: name is string. No TS7006 error, because the type isn't implicit, it's contextual.
  4. Now the body can be checked: name.toUpperCase() returns string. That return type is how U gets inferred: U = string.
  5. map returns U[], so upper is string[]. Context flowed into the callback (parameter types) and inference flowed out of it (the return type), in that order.
AdvancedContextual typing for DOM event handlers

The same thing happens with DOM events, where it's even more useful because the right type isn't obvious:

TypeScript
window.addEventListener("keydown", (event) => {
  console.log(event.key); // event: KeyboardEvent
});
What’s happening
  1. addEventListener on window has a generic overload, roughly addEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any): void.
  2. The first argument, "keydown", is checked first and infers K = "keydown".
  3. That fixes the listener slot's type: (ev: WindowEventMap["keydown"]) => any, and WindowEventMap["keydown"] is KeyboardEvent in the DOM type definitions.
  4. The arrow function is contextually typed by that slot, so event is KeyboardEvent and event.key is a known string property. Change "keydown" to "click" and event becomes MouseEvent, and event.key would be an error.
AdvancedContext from a variable's annotation

Context also comes from a variable's annotation, and it is lost the moment you move the function out of the slot:

TypeScript
type Comparator = (a: number, b: number) => number;
const ascending: Comparator = (a, b) => a - b; // a and b: number from Comparator

const handler = (event) => console.log(event.key); // ❌ Parameter 'event' implicitly has an 'any' type.
window.addEventListener("keydown", handler);
What’s happening
  1. ascending is annotated with a function type, so the arrow on the right is contextually typed by it: a and b are number and the body's number result is checked against the declared return type.
  2. handler is declared on its own line with no annotation. At that point there's no slot, so nothing tells the compiler what event is. In strict mode (noImplicitAny) that's TS7006: "Parameter 'event' implicitly has an 'any' type."
  3. Passing handler to addEventListener on the next line doesn't help. Contextual typing works only on the expression written directly in the slot; the compiler doesn't look ahead to see where a variable will be used later.
  4. The fixes: write the callback inline, annotate the parameter ((event: KeyboardEvent) => ...), or annotate the variable with a function type as ascending does.
AdvancedRefactoring pitfall: implicit any after extraction

#Primitive and literal types

Added

TypeScript has a type for each JavaScript primitive: string, number, boolean, bigint, symbol, null and undefined. They're written in lowercase. The capitalised String, Number and Boolean are the types of the wrapper objects (new String("x")), which you never want.

On top of those, every literal value can be a type of its own. "dark" is a type whose only value is the string "dark"; 42 is a type with one value; true is a type, and boolean is literally the union true | false. Literal types are how TypeScript models "one of these exact values", like type Theme = "light" | "dark", and they're the building blocks for discriminated unions and template literal types.

The thing to understand is widening. When the compiler infers a type from a literal, it has to guess how specific you meant to be. The rule is about mutability: if the place holding the value can change (a let, an object property, an array element), the literal widens to its primitive ("dark" → string). If it can't change (a const variable), the literal type is kept.

TypeScript
let a = "hello"; // let a: string
const b = "hello"; // const b: "hello"
let c = b; // let c: string
const d = b; // const d: "hello"

const e: "hello" = "hello";
let f = e; // let f: "hello"
What’s happening
  1. let a = "hello": a can be reassigned, so the literal "hello" is widened to string. Any string can go into a later.
  2. const b = "hello": b can never hold anything else, so its type stays the literal "hello". Internally this is a widening literal type: it remembers it came from an unannotated inference.
  3. let c = b: copying a widening literal into a mutable let widens it again, so c is string. const d = b keeps "hello" because d is immutable too.
  4. const e: "hello" is annotated. An annotated literal type is non-widening: you asked for exactly this type, so the compiler respects it everywhere. let f = e therefore gives f the type "hello", and only "hello" can ever be assigned to f.
  5. The point: widening is the compiler being helpful about variables you'll reassign, and you control it with let/const, annotations, and as const.
AdvancedWidening in object properties

Widening bites most often with object properties, because properties are mutable even when the variable holding the object is a const:

TypeScript
type Theme = "light" | "dark";
function setTheme(theme: Theme) {}

const config = { theme: "dark" }; // const config: { theme: string }
setTheme(config.theme); // ❌ Argument of type 'string' is not assignable to parameter of type 'Theme'.

const fixedByAsConst = { theme: "dark" } as const; // { readonly theme: "dark" }
setTheme(fixedByAsConst.theme);

const fixedByAnnotation: { theme: Theme } = { theme: "dark" };
setTheme(fixedByAnnotation.theme);
What’s happening
  1. config is a const, but config.theme = "light" (or "anything") would still be legal JavaScript. So the compiler widens the property to string, and config is { theme: string }.
  2. setTheme(config.theme) passes a string where "light" | "dark" is required. Not every string is a Theme, so TS2345: Argument of type 'string' is not assignable to parameter of type 'Theme'. The value really is "dark" at runtime, but the type says string, and the checker only sees types.
  3. as const tells the compiler "this literal will never change": every property becomes readonly and keeps its literal type, giving { readonly theme: "dark" }. "dark" is assignable to Theme, so the call compiles.
  4. The annotation version gives the property its type up front: theme is Theme, the literal "dark" is checked against it on the way in, and passing it on is fine. This is the better fix when the object has a known shape; as const suits fixed lookup tables.
AdvancedCloser look: bigint and other primitives

A few primitives deserve a closer look:

TypeScript
const big = 9007199254740993n; // const big: 9007199254740993n
let total = 10n; // let total: bigint
total = total + 1; // ❌ Operator '+' cannot be applied to types 'bigint' and '1'.

let id = Symbol("id"); // let id: symbol
const key = Symbol("key"); // const key: typeof key  (a unique symbol)

let wrapped: String = "text";
let plain: string = wrapped; // ❌ Type 'String' is not assignable to type 'string'.
What’s happening
  1. bigint literals (the n suffix) get literal types just like numbers: big is the literal type 9007199254740993n, and the let version widens to bigint. You need target ES2020 or later to use them.
  2. total + 1 mixes bigint and number. JavaScript throws a TypeError for that at runtime ("Cannot mix BigInt and other types"), and TypeScript catches it at the operator: TS2365: Operator '+' cannot be applied to types 'bigint' and '1'. Note the 1 in the message: the operand still has its literal type at that point. You'd write total + 1n.
  3. Symbol("id") assigned to a let is type symbol. Assigned to a const, it gets the special type unique symbol (shown as typeof key): a type that only this one symbol has. That's what lets symbols be used as typed property keys; see unique symbol.
  4. String (capital S) is the interface for wrapper objects. A primitive "text" is assignable to it, but not the other way round: TS2322: Type 'String' is not assignable to type 'string', with the hint "'string' is a primitive, but 'String' is a wrapper object. Prefer using 'string' when possible." Always use the lowercase types.
Advancednull and undefined under strictNullChecks

null and undefined are separate types too. With strictNullChecks on (part of strict), they're not included in string or number: let name: string = null is an error, and a value that may be missing must say so with a union like string | null. See strict and strictNullChecks.

#Arrays and readonly arrays

An array type describes the element type: every element of a number[] is a number. There are two spellings for the same type, number[] and Array<number>. They're identical to the compiler; the generic form is just the Array<T> interface written out. Most code uses T[] for simple element types and teams sometimes prefer Array<…> for long unions because it doesn't need parentheses.

Where it gets interesting is mixed arrays and read-only arrays. "An array that may contain strings and numbers" and "either an array of strings or an array of numbers" are different types, and the difference shows up in what you can put in them. Read-only arrays are a separate type with all the mutating methods removed, which is how you promise a function won't modify the array you pass it.

TypeScript
const scores: number[] = [90, 85];
const sameType: Array<number> = scores;

const ids: (string | number)[] = [1, "a2", 3];
const allOneKind: string[] | number[] = [1, "a2", 3]; // ❌ Type '(string | number)[]' is not assignable to type 'string[] | number[]'.

const noParens: string | number[] = "just a string";
What’s happening
  1. number[] and Array<number> are the same type, so assigning one to the other is trivially fine.
  2. (string | number)[] means each element may be a string or a number, independently. [1, "a2", 3] is fine.
  3. string[] | number[] means the whole array is all strings or all numbers. The literal [1, "a2", 3] is inferred as (string | number)[], which fits neither member, so TS2322: Type '(string | number)[]' is not assignable to type 'string[] | number[]', with the detail that string | number isn't assignable to string.
  4. The parentheses matter: string | number[] parses as string or number[], so a plain string is a valid value. Writing Array<string | number> avoids that trap.
  5. Which to use? (string | number)[] when items are genuinely mixed (a CSV row). string[] | number[] when you get one homogeneous list or another, though it's awkward to work with: calling push on it needs an argument that is a string and a number at once, so it's often better as a generic T[].
Advancedreadonly arrays reject mutation

Now readonly:

TypeScript
function sum(values: readonly number[]): number {
  values.push(0); // ❌ Property 'push' does not exist on type 'readonly number[]'.
  values[0] = 1; // ❌ Index signature in type 'readonly number[]' only permits reading.
  return values.reduce((acc, v) => acc + v, 0);
}

const nums = [1, 2, 3];
sum(nums);

const view: ReadonlyArray<number> = nums;
const backToMutable: number[] = view; // ❌ The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
type NotAllowed = readonly Array<number>; // ❌ 'readonly' type modifier is only permitted on array and tuple literal types.
What’s happening
  1. readonly number[] is shorthand for the ReadonlyArray<number> interface. That interface simply doesn't declare push, pop, splice, sort, reverse, fill or an assignable index. So values.push(0) is TS2339: Property 'push' does not exist on type 'readonly number[]', and values[0] = 1 is TS2542: Index signature in type 'readonly number[]' only permits reading.
  2. Reading methods (map, filter, reduce, slice, toSorted) are all there, because they return new arrays. reduce here is fine.
  3. sum(nums) passes a mutable number[] where a readonly one is expected. That's allowed: a mutable array can do everything a readonly one can, plus more. The function just promises not to use the "more".
  4. The reverse is blocked: assigning a ReadonlyArray<number> to number[] would hand mutating methods back to code that was promised a read-only view, so TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
  5. The readonly keyword only works on the bracket syntax (and tuples). On the generic form you must write ReadonlyArray<number>; readonly Array<number> is TS1354: 'readonly' type modifier is only permitted on array and tuple literal types.
AdvancedPitfall: readonly arrays at runtime
AdvancedEmpty arrays and optimistic indexing

Two more things that come up:

  • Empty arrays need an annotation. const ids = [] has no elements to infer from, so it starts as an "evolving" any[] whose type is built up from later push calls. When the compiler can't follow that (the array is read before anything is pushed, or used inside a closure), strict mode reports TS7034: "Variable 'ids' implicitly has type 'any[]' in some locations where its type cannot be determined." Writing const ids: number[] = [] avoids the guesswork.
  • Indexing is optimistic. scores[10] has type number, even though it's undefined at runtime. The noUncheckedIndexedAccess option makes every index access T | undefined, which is safer but noisier.

#Tuples

A tuple is an array with a fixed length where each position has its own type. [string, number] means exactly two elements, a string first and a number second. At runtime it's just an array; the tuple-ness exists only in the type checker.

Why bother? Because positional data is common and arrays type it badly. useState returns a value and a setter. Object.entries returns [key, value] pairs. A coordinate is [x, y]. With a plain array type, every element would be typed as the union of everything (string | number), and you'd lose which position holds what. A tuple keeps that information.

TypeScript
let entry: [string, number] = ["age", 42];
const [label, value] = entry; // label: string, value: number

entry = [42, "age"]; // ❌ Type 'number' is not assignable to type 'string'. (and the reverse for position 1)
entry = ["age"]; // ❌ Type '[string]' is not assignable to type '[string, number]'.
entry[2]; // ❌ Tuple type '[string, number]' of length '2' has no element at index '2'.
What’s happening
  1. entry is annotated as a tuple, so ["age", 42] is checked position by position: "age" against string, 42 against number. Both fit.
  2. Destructuring uses the per-position types: label is string and value is number. With (string | number)[] both would be string | number, and you'd need a check before calling label.toUpperCase().
  3. [42, "age"] has the right length but the wrong order. The checker compares each position and reports two errors on that line: TS2322: Type 'number' is not assignable to type 'string' for position 0, and Type 'string' is not assignable to type 'number' for position 1.
  4. ["age"] has the wrong length. A tuple type includes its length (here the literal type 2), and [string] has length 1, so TS2322: Type '[string]' is not assignable to type '[string, number]'.
  5. entry[2] reads past the end. Because the length is known, the compiler can reject it: TS2493: Tuple type '[string, number]' of length '2' has no element at index '2'.
AdvancedTuple vs union array inference

Tuple vs union array. The doc's "tuple vs union array" question comes down to what you know about positions, and also to how inference behaves. Array literals are never inferred as tuples:

TypeScript
const pair = ["age", 42]; // const pair: (string | number)[]
const first = pair[0]; // const first: string | number

function useToggle(initial: boolean): [boolean, () => void] {
  let on = initial;
  const toggle = () => {
    on = !on;
  };
  return [on, toggle];
}

const [isOpen, toggle] = useToggle(false); // isOpen: boolean, toggle: () => void
What’s happening
  1. ["age", 42] is inferred as (string | number)[]: the compiler assumes an array literal is a list that might grow, so it picks a common element type rather than a fixed shape. pair[0] is therefore string | number.
  2. useToggle declares its return type as the tuple [boolean, () => void]. That annotation is what makes the returned literal [on, toggle] a tuple: the return expression is contextually typed by it, and each position is checked separately.
  3. Without the annotation, the return type would be inferred as (boolean | (() => void))[], and the caller couldn't call toggle() without first checking it's a function. This is exactly the problem React's useState solves with a tuple return type.
  4. Destructuring the tuple gives isOpen: boolean and toggle: () => void, and because it's positional, the caller picks the names. That's the main reason hooks return tuples rather than objects.
  5. Other ways to get a tuple from a literal: as const (gives a readonly tuple of literals, readonly ["age", 42]) or annotating the variable.
AdvancedCommon use cases for tuples

Use cases for tuples:

  • Returning several values from a function, especially when callers will rename them (hooks, [error, result] pairs).
  • Pairs and records from APIs: Object.entries gives [string, T][], Map entries are [K, V].
  • Fixed-shape data: coordinates [x, y], RGB [r, g, b], CSV rows with known columns.
  • Function parameter lists: Parameters<typeof fn> is a tuple (next topic).

When a tuple grows past three or so positions, use an object instead. user[3] means nothing to a reader; user.email does.

AdvancedPitfall: mutating methods on tuples

#Named, optional and variadic tuples

Advanced

Plain tuples get you fixed positions. Since TypeScript 4.0 tuples can also have labels, optional elements and rest/variadic elements, which together let a tuple describe anything a function parameter list can. That's no coincidence: a parameter list is a tuple to the compiler (Parameters<typeof fn> gives you one).

Labels are documentation only; they don't change what's assignable. Optional elements (?) make the length a union. Rest elements (...T[]) allow any number of trailing (or leading, or middle) elements. Variadic elements (...T where T is a generic tuple) let you write generic functions that preserve exact tuple types, like concat.

TypeScript
type Range = [start: number, end: number];
type Point = [x: number, y: number, z?: number];

const flat: Point = [1, 2];
const deep: Point = [1, 2, 3];
type PointLength = Point["length"]; // type PointLength = 2 | 3

type Mixed = [name: string, number]; // allowed since TypeScript 5.2
What’s happening
  1. Range is just [number, number] with names attached. The labels appear in editor hovers and in error messages, so [start: number, end: number] is clearer than two anonymous numbers. Assignability ignores them: a [number, number] is a valid Range.
  2. z?: number makes the third element optional, so both [1, 2] and [1, 2, 3] are valid Points.
  3. Because the length varies, the length property's type is a union: Point["length"] is 2 | 3. The compiler still knows deep[2] exists in the type (as number | undefined) and that index 3 doesn't.
  4. Before TypeScript 5.2, labels were all-or-nothing and Mixed was error TS5084 ("Tuple members must all have names or all not have names"). Since 5.2 you can mix them, which mostly matters when tuples are built by spreading other tuples, some labelled and some not.

Rest elements and variadic generics:

TypeScript
type Row = [label: string, ...scores: number[]];
const row: Row = ["maths", 90, 85, 77];

type WithCallback = [...args: string[], callback: () => void];
const call: WithCallback = ["a", "b", () => {}];

function concat<T extends unknown[], U extends unknown[]>(a: [...T], b: [...U]): [...T, ...U] {
  return [...a, ...b];
}

const joined = concat([1, "a"], [true]); // const joined: [number, string, boolean]
What’s happening
  1. Row is "one string, then zero or more numbers". ["maths", 90, 85, 77] fits: position 0 is checked against string, every remaining element against number.
  2. Rest elements don't have to be last. WithCallback is "any number of strings, then a function", the shape of Node-style APIs whose callback comes last. The compiler matches the last element to callback and the rest to string.
  3. In concat, T and U are constrained to arrays. Writing the parameter types as [...T] instead of plain T is the trick: it tells the compiler "infer a tuple here", so [1, "a"] infers T = [number, string] rather than (string | number)[]. [true] infers U = [boolean].
  4. The return type [...T, ...U] spreads both tuples into one, giving [number, string, boolean]. A non-variadic signature like concat<T>(a: T[], b: T[]): T[] would have lost all positional information.
  5. The [...a, ...b] in the body is ordinary JavaScript spread; the compiler accepts it as [...T, ...U] because spreading two tuples types exactly that way.

The link to parameter lists:

TypeScript
function move(x: number, y: number, label?: string) {}

type MoveArgs = Parameters<typeof move>; // type MoveArgs = [x: number, y: number, label?: string]
const args: MoveArgs = [10, 20];
move(...args);

function log(...entry: [level: "info" | "warn", message: string]) {}
log("info", "started");
log("debug", "x"); // ❌ Argument of type '"debug"' is not assignable to parameter of type '"info" | "warn"'.
What’s happening
  1. Parameters<typeof move> extracts the parameter list as a labelled tuple, keeping the names and the optional label? (see ReturnType and Parameters).
  2. args is a valid MoveArgs with two elements, because the third is optional. Spreading it into move(...args) type-checks because the compiler knows exactly which positions it contains; spreading a plain number[] would fail ("A spread argument must either have a tuple type or be passed to a rest parameter").
  3. Going the other way, a rest parameter typed as a tuple (...entry: [level, message]) is exactly equivalent to two ordinary parameters (level, message). Callers see log(level: "info" | "warn", message: string) thanks to the labels.
  4. log("debug", "x") checks "debug" against the first tuple position and fails with TS2345: Argument of type '"debug"' is not assignable to parameter of type '"info" | "warn"'. Tuple-typed rest parameters are how generic code forwards "whatever arguments this function takes" without losing types.

For type-level tuple tricks (reversing, flattening, recursion), see Reverse a tuple and [Element types with T[number]](#element-types-with-t-number).

#Object types, optional and readonly properties

An object type lists the properties a value must have and the type of each. You can write it inline ({ name: string; age: number }), name it with a type alias, or declare it as an interface; for plain object shapes the three behave the same (the differences are in type vs interface).

Remember that TypeScript is structural: a value matches an object type if it has the required properties with compatible types. It doesn't matter what it was "declared as", and extra properties are generally fine (with one exception, excess property checking).

Two modifiers change what a property means. ? makes a property optional: it may be missing, and reading it gives T | undefined. readonly stops assignment to the property through this type. Both are compile-time only; they emit nothing.

TypeScript
interface User {
  readonly id: number;
  name: string;
  email?: string;
}

const user: User = { id: 1, name: "Ada" };
user.name = "Ada Lovelace";
user.id = 2; // ❌ Cannot assign to 'id' because it is a read-only property.

user.email.toLowerCase(); // ❌ 'user.email' is possibly 'undefined'.
if (user.email) {
  user.email.toLowerCase();
}
const domain = user.email?.split("@")[1]; // const domain: string | undefined
What’s happening
  1. The literal { id: 1, name: "Ada" } satisfies User: both required properties are present with the right types, and email is optional so leaving it out is fine.
  2. user.name = ... is allowed: name is an ordinary mutable property. user.id = 2 fails with TS2540: Cannot assign to 'id' because it is a read-only property. Note that user is a const, but that only stops reassigning the variable; readonly is what protects the property.
  3. Reading user.email gives string | undefined. Calling a method on it directly is TS18048: 'user.email' is possibly 'undefined'.
  4. Inside if (user.email) the checker narrows the property to string (the empty string is also excluded, which is fine here). Narrowing works on property access paths, not just variables, as long as nothing in between could change them.
  5. Optional chaining is the other common pattern: user.email?.split("@") short-circuits to undefined if email is missing, so domain is string | undefined, and the type says so.
AdvancedShallow readonly and the aliasing hole

readonly is shallow and it has a hole. Both are interview favourites:

TypeScript
interface Order {
  readonly items: string[];
  readonly customer: { name: string };
}

const order: Order = { items: [], customer: { name: "Ada" } };
order.items = ["pen"]; // ❌ Cannot assign to 'items' because it is a read-only property.
order.items.push("pen");
order.customer.name = "Grace";

const point: { readonly x: number } = { x: 1 };
const writable: { x: number } = point;
writable.x = 99;
console.log(point.x); // 99
What’s happening
  1. readonly items protects the property items: you can't point it at a different array, hence TS2540 on order.items = [...].
  2. The array inside is a normal string[], so order.items.push("pen") is fine. Same for order.customer.name: the customer property is read-only, but the object it points to has a mutable name. To protect the contents you'd need readonly string[] and readonly name (or a deep-readonly type, see DeepReadonly).
  3. The hole: point has a read-only x, but assigning it to a variable whose type has a mutable x is allowed. TypeScript doesn't consider readonly when checking assignability between object types (for compatibility with older code).
  4. writable and point are the same object, so writable.x = 99 changes what point.x reads: 1 → 99. The readonly was a promise about one reference, and a second reference didn't make that promise.
  5. Takeaway: readonly is a useful guard against accidental writes in your own code, not a security boundary. For runtime immutability, Object.freeze.
AdvancedEdge case: missing key vs undefined value

#Optional parameters vs default values

Both make a parameter something the caller can skip, but they differ in what the function body sees. With name?: string, a missing argument arrives as undefined, so inside the function name is string | undefined and you must handle that. With a default, name = "stranger", JavaScript substitutes the default before your code runs, so inside the function name is just string.

From the caller's side they look identical: both appear as name?: string in the signature. The default's type is inferred from the default value, so you rarely need to annotate it.

TypeScript
function greet(name?: string) {
  // name: string | undefined
  return `Hello, ${name ?? "stranger"}`;
}

function greetWithDefault(name = "stranger") {
  // name: string
  return `Hello, ${name.toUpperCase()}`;
}

greet();
greetWithDefault();
greetWithDefault(undefined);
greetWithDefault(null); // ❌ Argument of type 'null' is not assignable to parameter of type 'string | undefined'.
What’s happening
  1. In greet, name? means the argument may be omitted. Omitted arguments are undefined at runtime, so the parameter's type inside is string | undefined, and the body must deal with that; here with ??.
  2. In greetWithDefault, there's no annotation; the type string is inferred from the default "stranger". JavaScript runs the default whenever the argument is undefined, so by the time the body runs name can't be undefined, and the compiler types it as string. name.toUpperCase() needs no check.
  3. Both calls with no argument compile; both signatures show as (name?: string). Passing undefined explicitly also triggers the default, because JavaScript defaults fire on undefined, not on "missing".
  4. null doesn't trigger a default: greetWithDefault(null) would run null.toUpperCase() and crash. The compiler knows this, so the parameter accepts string | undefined only, and passing null is TS2345.
AdvancedOrdering rules for optional parameters

Ordering rules, and the difference between "optional" and "may be undefined":

TypeScript
function bad(a?: number, b: number) {} // ❌ A required parameter cannot follow an optional parameter.

function okay(a = 1, b: number) {}
okay(undefined, 2);
okay(2); // ❌ Expected 2 arguments, but got 1.

function log(message: string, level: string | undefined) {}
log("hi"); // ❌ Expected 2 arguments, but got 1.
log("hi", undefined);
What’s happening
  1. An optional parameter can't come before a required one, because there'd be no way to skip it positionally: TS1016: A required parameter cannot follow an optional parameter.
  2. A default in the middle is allowed, but it doesn't make the parameter skippable. The compiler types okay as (a: number | undefined, b: number): to use the default you must pass undefined explicitly. okay(2) supplies only one argument, so TS2554: Expected 2 arguments, but got 1.
  3. level: string | undefined is required: the caller must pass something, even if it's undefined. Only ? (or a default in last position) makes the argument omittable. This mirrors the optional-property vs | undefined distinction for objects.
  4. In practice: put optional and defaulted parameters last, and prefer defaults when there's a sensible value, because they remove the undefined handling from the body.
AdvancedDefaults with destructured options objects

For options-style functions, defaults combine with destructuring:

TypeScript
interface ConnectOptions {
  host?: string;
  port?: number;
}

function connect({ host = "localhost", port = 5432 }: ConnectOptions = {}) {
  return `${host}:${port}`; // host: string, port: number
}

connect();
connect({ port: 6543 });
What’s happening
  1. The annotation ConnectOptions describes what callers may pass: an object where both keys are optional.
  2. The destructuring defaults host = "localhost" and port = 5432 fill in missing keys, so inside the body host is string and port is number; the undefined has been removed by the defaults, just as with simple parameters.
  3. The final = {} is a default for the whole argument. Without it, connect() would destructure undefined and throw "Cannot destructure property 'host' of 'undefined'" at runtime; the compiler would also make the argument required.
  4. connect({ port: 6543 }) overrides one key and keeps the other's default. This is the usual shape for configurable functions in TypeScript.

#Excess property checking

TypeScript is structural, so normally an object with extra properties is perfectly acceptable wherever a smaller shape is expected: a { name, age } object is a valid { name }. But there's one special case. When you write an object literal directly where a type is expected, and the literal has a property the target type doesn't know about, the compiler reports an error.

The reasoning: a fresh object literal has no other references. If you pass { colour: "red" } straight into a function that expects { color?: string }, nothing anywhere can ever read colour; it's almost certainly a typo or a misunderstanding of the API. So the compiler applies a stricter check to these fresh literals only. Once the object has been stored in a variable, it might be used elsewhere with its full type, so the extra property could be meaningful, and the normal structural rules apply again.

TypeScript
interface Options {
  color?: string;
  size?: number;
}
function draw(options: Options) {}

draw({ colour: "red" }); // ❌ Object literal may only specify known properties, but 'colour' does not exist in type 'Options'. Did you mean to write 'color'?

const withSize = { colour: "red", size: 2 };
draw(withSize);

const typoOnly = { colour: "red" };
draw(typoOnly); // ❌ Type '{ colour: string; }' has no properties in common with type 'Options'.
What’s happening
  1. draw({ colour: "red" }) passes a fresh object literal. The excess property check sees colour, which Options doesn't declare, and reports TS2561 (with a spelling suggestion, because color is close): Object literal may only specify known properties, but 'colour' does not exist in type 'Options'. Did you mean to write 'color'?
  2. withSize is the same mistake routed through a variable. It's no longer fresh, so only the normal structural check runs: size: number matches, color is optional so its absence is fine, and the extra colour is ignored. No error, and the typo goes unnoticed.
  3. typoOnly is also not fresh, yet it errors, for a different reason: weak type detection. Options has only optional properties (a "weak type"), so almost anything would match it structurally. To keep that from being meaningless, the compiler requires at least one property in common. { colour: string } shares none, so TS2559: Type '{ colour: string; }' has no properties in common with type 'Options'.
  4. So there are two separate safety nets: excess property checks for fresh literals, and the weak-type check for all-optional targets. withSize slips through both because it's not fresh and it does share size.
AdvancedBypassing excess property checks

Ways the check is (intentionally) bypassed, and when that's fine:

  • Through a variable, as above. This is normal structural typing, not a loophole.
  • With a type assertion: draw({ colour: "red" } as Options) silences it. Don't; an assertion that hides a typo is the worst of both worlds (see Type assertions with as).
  • With an index signature: if the type has [key: string]: unknown, any extra key is declared, so nothing is "excess". That's right for genuinely open-ended bags, like HTML attributes (see Index signatures).

#any

any switches the type checker off for a value. Anything can be assigned to an any, an any can be assigned to anything, and every operation on it (property access, calls, arithmetic) is allowed and produces another any. It's the escape hatch that lets TypeScript coexist with untyped JavaScript.

From my notes: use any when working with data from dynamic or unknown sources (third-party libraries, JSON from APIs), but only when you have no better option. The better option is almost always unknown; any is mainly for migrating JavaScript and for the rare type you genuinely can't express.

Mental model: any isn't "a type that can hold anything" (that's unknown). It's "don't check this". And it's contagious: whatever you derive from an any is any too, so one any can quietly switch off checking far beyond where it was written.

TypeScript
let someValue: any = "Hello, World!";
someValue = 42; // No error, type is flexible
someValue.toFixed(2); // No error, though someValue could be any type

someValue = "back to a string";
someValue.toFixed(2); // still no error, but it throws at runtime
What’s happening
  1. someValue is declared any, so assigning a string and then a number are both allowed. With let someValue = "Hello, World!" the second assignment would be TS2322.
  2. someValue.toFixed(2) compiles because the compiler doesn't check anything about an any. Here it happens to work at runtime: the value is 42 and (42).toFixed(2) is "42.00".
  3. After someValue = "back to a string", the same toFixed(2) line still compiles. At runtime it throws TypeError: someValue.toFixed is not a function. The compiler isn't tracking the value; with any, it isn't tracking anything.
  4. That's the whole trade: any never gives you a compile error and never protects you. It turns TypeScript back into JavaScript for this value.
AdvancedHow any spreads through code

The contagion is the dangerous part, because it doesn't stay where you put it:

TypeScript
const data = JSON.parse('{"user":{"name":"Ada"}}'); // const data: any
const name = data.user.nmae; // const name: any  (typo not caught)
const upper: string = name.toUpperCase(); // any flows into a string variable

function getConfig(): any {
  return { retries: "3" };
}
const retries: number = getConfig().retries; // compiles; really a string
What’s happening
  1. JSON.parse is declared in the standard library as returning any (it predates unknown). So data is any, with no annotation in sight.
  2. data.user.nmae has a typo. Property access on any is unchecked and returns any, so name is any and the typo compiles. At runtime name is undefined.
  3. name.toUpperCase() is allowed (it's any) and returns any, and any is assignable to string, so upper is declared string without complaint. At runtime this line throws TypeError: Cannot read properties of undefined (reading 'toUpperCase').
  4. getConfig() returns any, so .retries is any and assigning it to a number compiles, even though the value is the string "3". Every later retries + 1 gives "31". The bug surfaces far from the any that caused it.
  5. This is why any is called contagious: the type of upper and retries looks precise, but the precision is fake. Typing data as unknown, or giving getConfig a real return type, would have caught all three.
AdvancedSources of any and how to block them

Where any comes from, and how to keep it out:

  • Explicitly: : any, as any. Lint with @typescript-eslint/no-explicit-any.
  • Implicitly: an unannotated parameter with no context. noImplicitAny (part of strict) turns that into an error.
  • From libraries: JSON.parse, response.json(), Promise.catch callbacks, loosely typed packages. Annotate the result as unknown (or a validated type) at the boundary. The @typescript-eslint/no-unsafe-* rules flag any values being assigned, called or returned.

Legitimate uses: incremental migration from JavaScript, test doubles where a full type is pointless, and occasionally inside a generic helper's implementation where the types are verified by its signature (and even then, a single well-commented as is better than an any that leaks out).

#unknown

unknown is the type-safe counterpart of any. Like any, every value is assignable to it: it's the "top type", the set of all values. Unlike any, you can't do anything with an unknown value (read a property, call it, pass it where a string is needed) until you've proven what it is. The compiler forces the check that any lets you skip.

Mental model: unknown is a sealed parcel. You can accept any parcel and pass it around, but you have to open it and look inside (narrow it) before you can use what's in it. any is a parcel you're allowed to use without looking, and you find out what it was when something breaks.

This is the example from my notes:

TypeScript
let input: unknown;
input = "Hello";
console.log(input.toUpperCase()); // ❌ 'input' is of type 'unknown'.

if (typeof input === "string") {
  console.log(input.toUpperCase()); // Works, narrowed
}
What’s happening
  1. input is declared unknown. Assigning "Hello" is fine, because anything is assignable to unknown.
  2. You might expect the assignment to narrow input to string (that's what happens with a declared union like string | number). It doesn't: assignment narrowing only picks among the members of a declared union, and unknown isn't one. The variable stays unknown.
  3. So input.toUpperCase() is TS18046: 'input' is of type 'unknown'. (My notes quote the older wording, "Object is of type 'unknown'"; current compilers name the variable.)
  4. Inside if (typeof input === "string"), the typeof guard narrows input from unknown to string, so toUpperCase() is allowed. The check is real JavaScript that runs, which is why it's trustworthy: you must check that input is a string before calling toUpperCase(), unlike with any.
AdvancedNarrowing parsed JSON from unknown

The main use: data whose type isn't known until runtime, such as parsed JSON, postMessage payloads, localStorage, caught errors. Type it unknown at the boundary, then narrow after verifying the structure:

TypeScript
interface User {
  name: string;
  age: number;
}

function parseUser(json: string): User {
  const data: unknown = JSON.parse(json);
  if (
    typeof data === "object" &&
    data !== null &&
    "name" in data &&
    typeof data.name === "string" &&
    "age" in data &&
    typeof data.age === "number"
  ) {
    return { name: data.name, age: data.age };
  }
  throw new Error("Not a valid user");
}
What’s happening
  1. JSON.parse returns any, but annotating data as unknown immediately converts it: from here on the compiler demands proof before any use. That one annotation is the whole defensive strategy.
  2. typeof data === "object" narrows unknown to object | null (because typeof null is "object" in JavaScript). data !== null removes null, leaving object.
  3. "name" in data narrows data to object & Record<"name", unknown>: the compiler now knows the property exists but not its type. Since TypeScript 4.9 this in narrowing works on plain object; before that you needed a type assertion.
  4. typeof data.name === "string" narrows the property access data.name to string. The same steps establish data.age as number.
  5. Inside the if, { name: data.name, age: data.age } builds a fresh object from the two narrowed properties, which satisfies User. Returning data itself wouldn't compile: narrowing a property's typeof doesn't narrow the parent object's type, so data is still only known to have name: unknown and age: unknown.
  6. If any check fails, we throw. Every path either returns a verified User or doesn't return, and there's no as User anywhere, so the compiler checked everything. In real code you'd move the checks into a custom type guard or use a schema library (see Validating runtime data).
Advancedany vs unknown side by side

The differences side by side:

anyunknown
Can hold any valueYesYes
Can be assigned to string, number, …Yes, uncheckedNo, only to unknown and any
Property access, calls, operatorsAllowed, results are anyError until narrowed
Effect on checkingSwitches it off, and spreadsForces you to check
Typical useMigration, escape hatchUntrusted input, generic "anything" parameters
Which lines are errors?
TypeScript
declare const a: any;
declare const u: unknown;

const s1: string = a;
const s2: string = u;
const n: unknown = 42;
a.foo.bar();
u.foo;
if (typeof u === "number") u.toFixed(1);
Show answer

Errors on the s2 line and the u.foo line; everything else compiles.

What’s happening
  1. const s1: string = a: any is assignable to every type, so this compiles, and it's exactly the kind of line that lets bad data in unnoticed.
  2. const s2: string = u: unknown is assignable only to unknown and any. TS2322: Type 'unknown' is not assignable to type 'string'.
  3. const n: unknown = 42: everything is assignable to unknown. Fine.
  4. a.foo.bar(): no checks on any, so it compiles (and may crash at runtime).
  5. u.foo: TS18046: 'u' is of type 'unknown'. You can't touch an unknown before narrowing.
  6. if (typeof u === "number") u.toFixed(1): inside the if, u is narrowed to number, so toFixed is allowed. The rule in one line: assigning to unknown is always fine; using an unknown requires a check.

#unknown in catch clauses

In JavaScript you can throw anything: an Error, a string, a number, null, a plain object. So when you catch, you genuinely don't know what you've got. For years TypeScript typed the catch variable as any, which meant err.message compiled even when err was a string.

TypeScript 4.4 added the useUnknownInCatchVariables option, and it's part of strict, so in any strict project the catch variable is unknown. That forces you to check what was thrown before using it, which is the honest type.

TypeScript
try {
  JSON.parse("{ not json");
} catch (err) {
  console.log(err.message); // ❌ 'err' is of type 'unknown'.

  if (err instanceof Error) {
    console.log(err.message); // err: Error
  } else {
    console.log("Non-error thrown:", String(err));
  }
}
What’s happening
  1. JSON.parse throws a SyntaxError for invalid input, and control jumps to the catch block with err bound to it.
  2. The compiler doesn't analyse what the try block can throw (TypeScript has no checked exceptions), so err is typed unknown under strict mode. err.message is TS18046: 'err' is of type 'unknown'.
  3. err instanceof Error narrows err to Error in the if branch, because instanceof checks the prototype chain at runtime. SyntaxError extends Error, so here this branch runs and logs the parse error message.
  4. The else branch handles everything else that could be thrown (throw "oops", throw 42). String(err) turns any value into something printable without risk. Without useUnknownInCatchVariables, err would be any and you'd never be pushed to write this branch.
AdvancedCatch annotation rules and an error helper

The annotation rules, and a reusable helper:

TypeScript
try {
  // ...
} catch (err: Error) { // ❌ Catch clause variable type annotation must be 'any' or 'unknown' if specified.
}

function getErrorMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  return String(err);
}

Promise.reject(new Error("boom")).catch((err) => {
  // err: any here, strict mode doesn't change Promise callbacks
  console.log(getErrorMessage(err));
});
What’s happening
  1. You may annotate a catch variable, but only as any or unknown. catch (err: Error) would be a lie (nothing guarantees an Error was thrown), so it's TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified.
  2. getErrorMessage accepts unknown and narrows step by step: an Error gives its message, a string is returned as is, anything else goes through String(). Every branch returns a real string, so callers get a safe value whatever was thrown.
  3. useUnknownInCatchVariables only affects catch (err) clauses. The callback of promise.catch(...) is typed by the Promise declaration in the standard library, where the reason is any. So err there is any, and strict mode won't stop err.message.
  4. The habit: annotate it yourself as .catch((err: unknown) => …), or route it straight through a helper like getErrorMessage. The lint rule @typescript-eslint/use-unknown-in-catch-callback-variable enforces this.
AdvancedEdge case: errors from other realms

#never

never is the type with no values at all: the empty set. No value can ever have type never, so if the compiler says an expression is never, it's saying "this can't happen". It's the opposite end from unknown: unknown contains every value (top type), never contains none (bottom type).

Mental model: think of types as sets of values. string | number is the union of two sets. never is the empty set, so string | never is just string (adding nothing changes nothing), and string & number is never (no value is both). This set view explains every place never shows up:

  • A function that never returns (it always throws or loops forever) has return type never: there's no value it can produce.
  • A variable narrowed until no possibilities are left has type never, which is what powers exhaustiveness checking.
  • An impossible intersection like string & number is never.
  • Conditional types return never to drop union members, which is how Exclude works (see Exclude and Extract).

The examples from my notes:

TypeScript
function throwError(message: string): never {
  throw new Error(message);
}

function fail(): never {
  while (true) {}
}

function parsePort(value: string | undefined): number {
  if (value === undefined) throwError("PORT is not set");
  return Number.parseInt(value, 10); // value: string here
}
What’s happening
  1. throwError always throws, so execution never reaches its end and it never produces a value. Its return type is never. The compiler verifies that: if the body could fall off the end, you'd get TS2534: A function returning 'never' cannot have a reachable end point.
  2. fail loops forever, which also never returns. Same type, same reasoning.
  3. In parsePort, value starts as string | undefined. The if branch calls throwError, and because that call has return type never, the compiler treats it like a throw: the code after it on that path is unreachable.
  4. So after the if, the only way to get there is value !== undefined, and value is narrowed to string. Number.parseInt(value, 10) needs a string, and gets one. Without the never return type, value would still be string | undefined and that line would be an error.
  5. That's the practical point of never return types: they're not just documentation, they teach the control-flow analysis that this call ends the path.
AdvancedInferring never return types

There are two subtleties in how never return types are inferred and used, and both catch people out:

TypeScript
function crash() {
  throw new Error("boom");
} // function crash(): void

const crashArrow = () => {
  throw new Error("boom");
}; // const crashArrow: () => never

const failFast = (msg: string): never => {
  throw new Error(msg);
};

function check(value: string | number) {
  if (typeof value !== "string") failFast("not a string");
  value.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}
What’s happening
  1. A function declaration that only throws is inferred as returning void, not never. This is deliberate: a base-class method or stub that throws "not implemented" is meant to be overridden by one that returns normally, and inferring never would break those overrides. If you want never, annotate it.
  2. A function expression or arrow function that only throws is inferred as () => never. There's no override story for those, so the compiler infers the precise type.
  3. failFast explicitly returns never, yet calling it doesn't narrow value in check. The control-flow analysis only treats a call as "ends the path" when the callee has an explicit type annotation it can see without inference: a function declaration with : never, or a const whose variable is annotated with a function type. Here only the arrow's return type is annotated, not the failFast variable.
  4. So after the if, value is still string | number, and value.toUpperCase() is TS2339: Property 'toUpperCase' does not exist on type 'string | number'. The fix is function failFast(msg: string): never { … }, or const failFast: (msg: string) => never = ….
Advancednever from impossible intersections

Never from impossible intersections (my notes said "interactions"; they're intersections):

TypeScript
type Impossible = string & number; // type Impossible = never
type Clash = { kind: "a" } & { kind: "b" }; // type Clash = never
type Conflict = { id: string } & { id: number };
type ConflictId = Conflict["id"]; // type ConflictId = never

type Cleaned = string | never; // type Cleaned = string
What’s happening
  1. string & number asks for values that are both a string and a number. There are none, so the compiler reduces it to never immediately.
  2. { kind: "a" } & { kind: "b" } would need a kind property that is both "a" and "b". Because kind holds literal types (a discriminant), the compiler knows no object can satisfy this and reduces the whole intersection to never (TypeScript 3.9+).
  3. { id: string } & { id: number } is different: string and number aren't literal discriminants, so the intersection isn't collapsed as a whole. The type stays an object type, but its id property is string & number, which is never. You'd only notice when you try to create one: no value can go in id. This is the silent failure mode of & that extends vs intersection is about.
  4. string | never is just string: adding the empty set to a union adds nothing. That's why conditional types can return never to remove a member from a union.
Advancednever at the bottom of assignability

never also sits at the bottom of assignability: a never value is assignable to every type (an empty set is a subset of everything), but nothing is assignable to never, not even any. That's what makes const _exhaustive: never = shape a compile-time check: it compiles only if every case was handled and shape has been narrowed down to nothing.

What's the type of x in each branch?
TypeScript
function describe(x: string | number) {
  if (typeof x === "string") {
    // A
  } else if (typeof x === "number") {
    // B
  } else {
    // C
  }
}
Show answer

A: string. B: number. C: never.

What’s happening
  1. x starts as string | number.
  2. The first typeof check narrows the if branch to string (A), and the remaining possibilities for the rest of the chain to number.
  3. The second check narrows branch B to number, and the remaining set is now empty.
  4. In branch C no member of the union is left, so x is never. The branch can't run unless someone lies to the type system (an as any caller). That's precisely the spot where exhaustiveness checks go: assign x to a never variable, and if someone later adds boolean to the parameter, C's x becomes boolean and the assignment fails, pointing you at the unhandled case.

#void vs never

From my notes: void is used when a function doesn't return anything useful: it completes, but returns no value. never is used when a function never completes: it throws or runs forever.

At runtime, a void function does return, with the value undefined (every JavaScript function call produces a value). void says "don't use what comes back". A never function doesn't return at all; control goes to a catch somewhere, or nowhere.

voidnever
Does the call return?YesNo: throws or loops forever
Runtime valueundefined (or ignored)None, there's no return
Code after the callRunsUnreachable (with an explicit annotation)
As a set of valuesReturn value to ignoreThe empty set
Typical useEvent handlers, loggers, settersthrowError, assertNever, process exit
TypeScript
function logMessage(msg: string): void {
  console.log(msg);
}

function exitWith(code: number): never {
  process.exit(code);
}

const result = logMessage("hi"); // const result: void
if (result) {} // ❌ An expression of type 'void' cannot be tested for truthiness.
What’s happening
  1. logMessage runs console.log and falls off the end, so at runtime the call returns undefined. The : void annotation says that value isn't meaningful.
  2. exitWith calls process.exit, which @types/node declares as returning never. The process ends, the function never returns, so never is the right return type, and code after exitWith(1) is known to be unreachable.
  3. result is typed void. You're allowed to store it, but the compiler steers you away from using it: testing a void for truthiness is TS1345: An expression of type 'void' cannot be tested for truthiness. If you're testing it, you've misunderstood what the function does.
AdvancedCallbacks typed void may return values

The important void behaviour (and the great interview point): a function type whose return type is void accepts functions that return something.

TypeScript
const numbers = [1, 2, 3];
const copy: number[] = [];
numbers.forEach((n) => copy.push(n)); // push returns a number, and that's fine

type Callback = () => void;
const getsIgnored: Callback = () => "a string";
const value = getsIgnored(); // const value: void
value.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'void'.

function shout(): void {
  return "HI"; // ❌ Type 'string' is not assignable to type 'void'.
}
What’s happening
  1. forEach declares its callback as (value: number, index: number, array: number[]) => void. The arrow (n) => copy.push(n) returns a number (the new length), because a concise arrow body returns its expression.
  2. That's accepted. A function type with a void return means "the caller will ignore whatever you return", not "you must return nothing". forEach never looks at the return value, so returning a number harms no one. If this weren't allowed, every concise arrow like n => copy.push(n) would need braces.
  3. Same with getsIgnored: an arrow returning a string is assignable to () => void. At runtime getsIgnored() really does return "a string", but the type of the call is void, so value.toUpperCase() is TS2339: Property 'toUpperCase' does not exist on type 'void'. The type system holds the caller to the promise of ignoring it.
  4. A function declaration with an explicit : void annotation is different: there you're stating what this function returns, so return "HI" is TS2322: Type 'string' is not assignable to type 'void'. The substitutability rule only applies when a function is assigned to a void-returning function type.
AdvancedEdge case: undefined return type annotations
Which of these compile?
TypeScript
type Handler = () => void;

const a: Handler = () => 42;
function b(): void { return 42; }
const c: () => undefined = () => 42;
const d: Handler = () => { throw new Error(); };
const e: () => never = () => {};
Show answer

a and d compile. b, c and e are errors.

What’s happening
  1. a: an arrow returning number assigned to a void-returning function type. Allowed by the substitutability rule: the return value will be ignored.
  2. b: a declaration with an explicit : void return type returning 42 is TS2322: Type 'number' is not assignable to type 'void'. The rule doesn't apply to the function's own declared return type.
  3. c: undefined as a return type is strict. () => number isn't assignable to () => undefined because number isn't undefined.
  4. d: the arrow is inferred as () => never, and never is assignable to everything, including void. A handler that always throws is a valid handler.
  5. e: () => {} returns void (it completes normally), and void isn't assignable to never. A function that returns can't pretend it doesn't: Type 'void' is not assignable to type 'never'.

#void vs Promise<void> and async callbacks

An async function always returns a promise. If it doesn't return anything, its type is () => Promise<void>: "a promise that fulfils with nothing useful". That's very different from () => void, but because of the rule in the previous topic, an async function is assignable to a void-returning function type. A Promise<void> is a value, and void return types accept any value.

So the compiler happily lets you pass an async function wherever a plain callback is expected, and the API you passed it to will ignore the promise. It won't wait for it, and nobody handles it if it rejects. This is the "async callbacks can bite you" problem: the types line up, the behaviour doesn't.

TypeScript
const saved: string[] = [];

async function save(id: string): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 10));
  saved.push(id);
}

async function saveAllBroken(ids: string[]) {
  ids.forEach(async (id) => {
    await save(id);
  });
  console.log("broken:", saved.length); // broken: 0
}

async function saveAll(ids: string[]) {
  await Promise.all(ids.map((id) => save(id)));
  console.log("fixed:", saved.length); // fixed: 4
}

await saveAllBroken(["a", "b"]);
await saveAll(["c", "d"]);
What’s happening
  1. forEach expects (value: string) => void. The async arrow has type (id: string) => Promise<void>, and since a void return accepts anything, it type-checks with no warning.
  2. At runtime forEach calls the arrow for "a": it starts save("a"), hits await inside save (a 10 ms timer) and immediately returns a pending promise. forEach throws that promise away and calls the arrow for "b", which does the same. Then forEach returns.
  3. console.log runs straight away. Neither timer has fired, so saved is still empty: it logs broken: 0. await saveAllBroken(...) resolves at this point too, because saveAllBroken itself had nothing to wait for.
  4. saveAll maps each id to the promise returned by save, so ids.map(...) is Promise<void>[]. Promise.all combines them and await pauses until all of them fulfil.
  5. By then the timers for "a" and "b" from the broken call have fired as well (they were scheduled earlier), so saved is ["a", "b", "c", "d"] and it logs fixed: 4. The broken version's work did eventually happen; nothing waited for it. If you need the saves to happen one at a time, for (const id of ids) await save(id); is the sequential alternative.
AdvancedUnhandled rejections from async callbacks

The worse half is error handling, because a rejected promise isn't a thrown exception:

TypeScript
type Listener = () => void;

function onClick(listener: Listener) {
  try {
    listener();
  } catch (err) {
    console.log("handled:", err);
  }
}

onClick(async () => {
  throw new Error("boom");
});
What’s happening
  1. onClick was written for synchronous listeners, and it wraps the call in try/catch to be safe. The async arrow is assignable to Listener for the same reason as before.
  2. Calling an async function never throws synchronously. The throw inside turns into a rejected promise, which listener() returns normally. The try block completes and the catch never runs.
  3. Nobody holds the rejected promise, so nobody attaches a handler. Node detects an unhandled rejection and, by default since Node 15, crashes the process with the error. In a browser it shows up as an "Uncaught (in promise)" error.
  4. The type system can't help here because () => void truthfully says "the return value is ignored", and that's exactly what happened. The bug is in the mismatch between an API that ignores results and a callback whose result is its only way to report failure.
AdvancedTyping awaited callbacks as Promise<void>

The opposite direction is checked: if an API declares that it will await the callback, it must say so with Promise<void>, and then a plain function doesn't fit:

TypeScript
async function withRetry(task: () => Promise<void>, attempts = 3) {
  for (let i = 1; i <= attempts; i++) {
    try {
      await task();
      return;
    } catch (err) {
      if (i === attempts) throw err;
    }
  }
}

withRetry(() => console.log("sync")); // ❌ Type 'void' is not assignable to type 'Promise<void>'.
withRetry(async () => console.log("async"));
What’s happening
  1. withRetry takes () => Promise<void> because it awaits the task. Rejections arrive in its try/catch via await, so they're handled.
  2. () => console.log("sync") returns void (console.log is declared that way). void isn't a Promise<void>, so TS2322: Type 'void' is not assignable to type 'Promise<void>'. Assignability is asymmetric: anything fits a void return; only promises fit a Promise<void> return.
  3. The async version returns Promise<void> and fits. If you want to accept both, type the callback () => void | Promise<void> and still await task(): awaiting a non-promise just gives back the value.
AdvancedRules for async callbacks

Practical rules:

  • Don't pass async functions to APIs that don't await them: forEach, addEventListener, setTimeout, React useEffect (whose callback must return nothing or a cleanup function, not a promise). Use for…of with await, Promise.all, or a sync wrapper that calls an async function and handles its errors: button.onclick = () => { void save().catch(report); }.
  • When you write an API that takes a callback, decide whether you'll await it, and type it Promise<void> (or void | Promise<void>) if so.
  • Turn on @typescript-eslint/no-misused-promises (flags a promise-returning function passed where void is expected) and @typescript-eslint/no-floating-promises (flags promises nobody awaits or handles). They catch exactly what the type system deliberately allows.
  • See Typing async functions and promises for return types of async functions.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.