TypeScript NotesRohit’s interview study guide
Chapter 10

Utility Types

The built-in type transformers — what each does, how it's built, and where it bites.

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

#How utility types work

A utility type is a generic type alias that ships with TypeScript. You hand it a type, and it hands you back a new type: Partial<User> is "User, but every property optional"; ReturnType<typeof f> is "whatever f returns". There are about twenty of them, they're global (no import), and they all live in the compiler's standard library file lib.es5.d.ts.

The mental model that makes them click: utility types are functions on types. Partial<User> reads exactly like a function call partial(User) — the angle brackets are the parentheses, T is the parameter, and the right-hand side of the type declaration is the function body. The checker "calls" it at compile time, substitutes User for T, evaluates the body, and the result is a brand-new type. Like a pure function, it never changes its input: after type Draft = Partial<User>, User is exactly as strict as before. And like every other type, the result is erased — none of this exists in the emitted JavaScript.

Almost all of them are built from three pieces of type-level machinery you'll meet properly in Mapped & Conditional Types: mapped types that loop over keys (Partial, Required, Readonly, Pick, Record), conditional types that branch, distribute over unions and pattern-match with infer (Exclude, Extract, ReturnType, Parameters, Awaited and friends), and a plain intersection (NonNullable). A few are intrinsic — implemented inside the compiler because they can't be written in TypeScript (Uppercase and the other string types, NoInfer) — and one, ThisType, is just a marker the compiler recognises by name.

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

type UserPatch = Partial<User>;
// type UserPatch = { id?: number; name?: string; email?: string }

const patch: UserPatch = { email: "new@example.com" };
const full: User = { email: "new@example.com" }; // ❌ TS2739: Type '{ email: string; }' is missing the following properties from type 'User': id, name
What’s happening
  1. Partial<User> is a "call": the checker finds type Partial<T> = { [P in keyof T]?: T[P] } in lib.es5.d.ts and binds the parameter T to User.
  2. It evaluates the body for that argument: it walks the keys "id" | "name" | "email" and produces one optional property per key. The result is a new anonymous object type, which the alias UserPatch names.
  3. patch only has email, which is fine — every property of UserPatch is optional.
  4. full is annotated with the original User, and the compiler still demands id and name (TS2739). This is the "pure function" point: making UserPatch didn't loosen User at all.
  5. In the emitted JavaScript both lines are just const patch = { email: … } — utility types are a compile-time calculation only. They can describe a value; they can't change one.
AdvancedComposing utility types inside-out

Because they're functions, they compose, and they evaluate inside-out like nested calls:

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

type PublicProfile = Readonly<Pick<User, "name" | "email">>;
// type PublicProfile = { readonly name: string; readonly email: string }

const profile: PublicProfile = { name: "Rohit", email: "rohit@example.com" };
profile.name = "R"; // ❌ TS2540: Cannot assign to 'name' because it is a read-only property.
What’s happening
  1. The innermost call runs first: Pick<User, "name" | "email"> produces { name: string; email: string } — two of the four properties.
  2. That result becomes the argument to Readonly<…>, which walks its keys (name, email) and adds readonly to each.
  3. passwordHash and id never appear in the output, so PublicProfile is safe to send to a client — at the type level (whether the object still carries them is a separate question; see Pick).
  4. Assigning to profile.name fails with TS2540, because the outer Readonly marked it read-only.
AdvancedReading lib.d.ts and the full utility list

Where they live, and how to read them: in your editor, Go to Definition on Partial jumps straight into lib.es5.d.ts. In TypeScript 5.x that file is in node_modules/typescript/lib/. In TypeScript 7 (the native Go compiler) the lib files ship inside the platform package, e.g. node_modules/@typescript/typescript-darwin-arm64/lib/lib.es5.d.ts. Every definition quoted in this chapter is copied from that file (TypeScript 7.0). Reading them is the fastest way to answer "what exactly does this do with optional properties / unions / generics?".

The full set, grouped by how they're built:

FamilyUtility typesBuilt with
Change modifiersPartial, Required, Readonlyhomomorphic mapped type over keyof T
Choose keysPick, Omitmapped type over a key union (Omit = Pick + Exclude)
Build objectsRecordmapped type over any key union
Filter unionsExclude, Extract, NonNullabledistributive conditional type / intersection with {}
Take functions apartReturnType, Parameters, ConstructorParameters, InstanceTypeconditional type + infer
PromisesAwaitedrecursive conditional type + infer
StringsUppercase, Lowercase, Capitalize, Uncapitalizecompiler intrinsic
MarkersThisType, NoInfer (see type argument inference)recognised by name / intrinsic
AdvancedWhen to reach for a utility type

When to reach for one: whenever a type is a variation of another type you already have. Deriving UserPatch from User means that when someone adds a field to User, every derived type picks it up automatically, instead of a hand-copied interface silently drifting out of date. When not to: if the derived type has genuinely different meaning (an API contract you want frozen even if the database model changes), write it out explicitly — coupling it to User would be the bug.

#Partial<T>

Partial<T> makes every property of T optional. It exists because the same entity often travels in "incomplete" form: the body of a PATCH request, a form that's half filled in, the overrides passed to a function that has defaults, a test fixture builder. Without it you'd maintain a second interface with a ? on every line and keep the two in sync by hand.

Think of it as turning a required checklist into a "tick any that apply" checklist: the list of possible items stays the same, but none of them is mandatory any more.

From my notes:

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

const Person: Partial<Person> = { name: "Hello" };
const nobody: Partial<Person> = {};
const wrongType: Partial<Person> = { name: 42 }; // ❌ TS2322: Type 'number' is not assignable to type 'string'.
const typo: Partial<Person> = { nmae: "Hello" }; // ❌ TS2353: Object literal may only specify known properties, and 'nmae' does not exist in type 'Partial<Person>'.
What’s happening
  1. Partial<Person> evaluates to { name?: string; age?: number }, so { name: "Hello" } — with no age — is valid. So is {}: every property is optional, so the empty object satisfies it.
  2. const Person: Partial<Person> looks odd but compiles: types and values live in separate namespaces, so a variable called Person and an interface called Person don't clash. It's legal, just confusing — the other examples use lower-case names.
  3. Optional doesn't mean "any type": when name is present it must still be a string, so 42 fails with TS2322.
  4. Optional doesn't mean "any key" either: nmae isn't a property of Partial<Person>, and excess property checking still applies to object literals (TS2353). Partial relaxes presence, nothing else.
AdvancedPartial for update functions

The real-world use: update functions

TypeScript
interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
}

function updateUser(user: User, changes: Partial<Omit<User, "id">>): User {
  return { ...user, ...changes };
}

const rohit: User = { id: 1, name: "Rohit", email: "rohit@example.com", isAdmin: false };

const renamed = updateUser(rohit, { name: "Rohit V" });
const promoted = updateUser(rohit, { isAdmin: true, email: "admin@example.com" });
updateUser(rohit, { id: 2 }); // ❌ TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<User, "id">>'.
What’s happening
  1. The changes type is built inside-out: Omit<User, "id"> removes id (an id should never be patched), then Partial makes the remaining name, email and isAdmin optional.
  2. updateUser(rohit, { name: "Rohit V" }) passes one field. At runtime { ...user, ...changes } copies all four of rohit's properties, then changes overwrites name: the result is { id: 1, name: "Rohit V", email: "rohit@example.com", isAdmin: false }. rohit itself is untouched.
  3. The return type is User, and the spread type checks against it: the compiler treats each optional property of changes as "if present, it's a string", so the merged object is a complete User.
  4. { id: 2 } is rejected because id was omitted before Partial ran — the type prevents callers from changing the primary key.
  5. This is the shape of almost every PATCH handler, Redux-style setState(partial) and "options with defaults" function: one complete object, one Partial of overrides, merged with spread.
AdvancedHow Partial is built

How it's built

lib.es5.d.ts
/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

Evaluated for Partial<Person>:

What’s happening
  1. keyof T with T = Person is the union of its keys, "name" | "age". The mapped type [P in …] loops over that union, binding P to one key at a time.
  2. P = "name": the property is written name?: Person["name"]. The indexed access Person["name"] is string, so the output gets name?: string.
  3. P = "age": likewise age?: Person["age"] → age?: number.
  4. The ? after the brackets is the only thing Partial adds. The result: { name?: string; age?: number }.
  5. Because the loop is over keyof T (a homomorphic mapped type), existing modifiers are copied across — a readonly id stays readonly id?. It even works on arrays and tuples: Partial<[string, number]> is [string?, number?]. How to write this and its siblings yourself is in Rebuilding Pick, Required and Omit.
AdvancedGotcha: Partial is shallow

Gotcha 1: Partial is shallow

TypeScript
interface Settings {
  theme: { color: string; fontSize: number };
  notifications: boolean;
}

const patch: Partial<Settings> = {
  theme: { color: "dark" }, // ❌ TS2741: Property 'fontSize' is missing in type '{ color: string; }' but required in type '{ color: string; fontSize: number; }'.
};
What’s happening
  1. Partial<Settings> maps over Settings' own keys only: theme and notifications. It produces { theme?: { color: string; fontSize: number }; notifications?: boolean }.
  2. The value type of theme is copied as is — T[P] is the original nested object type, still with both properties required.
  3. So you may leave theme out entirely, but if you include it, it must be complete. { color: "dark" } is missing fontSize, hence TS2741 on the theme line.
  4. This matches how the runtime merge behaves anyway: { ...settings, ...patch } is also shallow, so a partial theme would replace the whole nested object and lose fontSize. The type is protecting you from that.
  5. If you really want "optional all the way down", you need a recursive DeepPartial — and as my notes say, a custom deep partial will break you 9000 times (arrays, functions, Date, Map…). It's worked through in DeepPartial.
AdvancedGotcha: optional allows missing or undefined

Gotcha 2: optional means "may be missing" and "may be undefined"

What does this print?
TypeScript
interface User {
  id: number;
  name: string;
}

function updateUser(user: User, changes: Partial<User>): User {
  return { ...user, ...changes };
}

const result = updateUser({ id: 1, name: "Rohit" }, { name: undefined });
console.log(result.name.toUpperCase());
Show answer

It compiles, then throws TypeError: Cannot read properties of undefined (reading 'toUpperCase').

What’s happening
  1. Without the exactOptionalPropertyTypes flag, an optional property name?: string really means name?: string | undefined. So { name: undefined } is a perfectly valid Partial<User>.
  2. The compiler types the spread { ...user, ...changes } as a complete User: for an optional property in the right-hand object, it assumes "if it's there, it's a string" and otherwise keeps the left-hand name. It doesn't model "present, but undefined".
  3. At runtime the spread copies own properties, including ones whose value is undefined. changes has an own name key, so name: "Rohit" is overwritten with name: undefined.
  4. result is typed User, so result.name.toUpperCase() compiles — and crashes, because result.name is undefined.
  5. Fixes: turn on exactOptionalPropertyTypes (then { name: undefined } is a compile error unless you write name?: string | undefined), or strip undefined values before merging. It's the most common way a Partial-based update function lies.
AdvancedWhere Partial fits and where it doesn't

Where it fits and where it doesn't: use Partial for inputs — patches, overrides, drafts. Be suspicious of Partial on outputs or long-lived state: a Partial<User> that's "really a full user once loading finishes" forces optional checks everywhere and hides real bugs. A discriminated union ({ status: "loading" } | { status: "ready"; user: User }) usually models that better (see Discriminated unions).

#Required<T>

Required<T> is the opposite of Partial: it removes the ? from every property. It exists for the moment after defaults have been applied. A function's input can be loose (timeout?: number), but once you've filled in the gaps, the resolved object is complete, and you want the type to say so — otherwise every later read needs a pointless ?? 0 or !.

The mental model: Partial turns a form's fields into "optional"; Required is the validation step that says "every field is now filled in".

From my notes:

TypeScript
interface NewPerson {
  name?: string;
  age?: number;
}

const hello: Required<NewPerson> = { name: "hello", age: 45 };
const missing: Required<NewPerson> = { name: "hello" }; // ❌ TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'Required<NewPerson>'.
What’s happening
  1. NewPerson has two optional properties, so {} would be a valid NewPerson.
  2. Required<NewPerson> evaluates to { name: string; age: number } — same keys, same value types, no ?.
  3. hello provides both, so it compiles.
  4. missing leaves out age, which is now mandatory: TS2741. Note that the error names the type Required<NewPerson> — the compiler keeps the alias name in messages and hovers.
AdvancedRequired for options with defaults

The real-world use: options with defaults

TypeScript
interface RetryOptions {
  retries?: number;
  delayMs?: number;
  backoff?: "fixed" | "exponential";
}

const DEFAULTS: Required<RetryOptions> = {
  retries: 3,
  delayMs: 200,
  backoff: "exponential",
};

function resolveOptions(options: RetryOptions = {}): Required<RetryOptions> {
  return { ...DEFAULTS, ...options };
}

const opts = resolveOptions({ retries: 5 });
const firstDelay = opts.delayMs * 2; // number — no undefined check needed
What’s happening
  1. Callers see the loose RetryOptions: every field optional, so resolveOptions() and resolveOptions({ retries: 5 }) both work.
  2. DEFAULTS is typed Required<RetryOptions>. That's the important line: if someone adds jitter?: boolean to RetryOptions later, DEFAULTS stops compiling until a default is supplied. The defaults can't silently fall behind the interface.
  3. { ...DEFAULTS, ...options } starts with a complete object and lets the caller's fields overwrite it: for { retries: 5 } the result is { retries: 5, delayMs: 200, backoff: "exponential" }.
  4. The return type is Required<RetryOptions>, so opts.delayMs is number, not number | undefined — the arithmetic needs no guard.
  5. The same hole as Partial applies: resolveOptions({ retries: undefined }) compiles and overwrites the default with undefined at runtime. exactOptionalPropertyTypes closes it.
AdvancedHow Required is built

How it's built

lib.es5.d.ts
/**
 * Make all properties in T required
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};
What’s happening
  1. For Required<NewPerson>, keyof T is "name" | "age", and the loop visits each key, just like Partial.
  2. P = "name": the original property is name?: string. The -? modifier means "remove the optional flag", so the output is name: string.
  3. P = "age": age?: number becomes age: number.
  4. -? also removes the undefined that came with the ?. Without exactOptionalPropertyTypes, name?: string is read as string | undefined; Required gives back plain string.
  5. The - prefix is the general "remove a modifier" syntax (-readonly exists too); see Modifier operators. Rebuilding Required by hand is in Rebuilding Pick, Required and Omit.

#Readonly<T>

Readonly<T> marks every property of T as readonly, so assigning to any of them is a compile error. It exists to express "I'm giving you this object to read, not to change" — function parameters you promise not to mutate, React props and state, shared configuration, values in a cache that other code also holds.

The mental model is a glass display case: you can look at everything inside, but the compiler won't let you reach in and swap something. Two caveats come with that picture, and both are interview favourites: the case only covers the top shelf (it's shallow), and it's made of type-checker glass — the JavaScript object underneath is as mutable as ever.

From my notes:

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

const thirdPerson: Readonly<Person> = { name: "eee", age: 35 };
thirdPerson.name = "ddd"; // ❌ TS2540: Cannot assign to 'name' because it is a read-only property.
console.log(thirdPerson.name); // reading is fine
What’s happening
  1. Readonly<Person> evaluates to { readonly name: string; readonly age: number }.
  2. Creating the object is fine — readonly restricts assignment after creation, not initialisation.
  3. thirdPerson.name = "ddd" is an assignment to a readonly property: TS2540.
  4. Note this is different from const: const thirdPerson stops you re-pointing the variable at a new object; Readonly stops you changing the properties of the object. You often want both.
AdvancedReadonly for parameters you won't mutate

The real-world use: parameters you won't mutate

TypeScript
interface CartItem {
  sku: string;
  qty: number;
}

interface Cart {
  items: CartItem[];
  coupon?: string;
}

function addItem(cart: Readonly<Cart>, item: CartItem): Cart {
  cart.coupon = undefined; // ❌ TS2540: Cannot assign to 'coupon' because it is a read-only property.
  return { ...cart, items: [...cart.items, item] };
}

function addItemSneaky(cart: Readonly<Cart>, item: CartItem): Cart {
  cart.items.push(item); // compiles — Readonly is shallow
  return cart;
}
What’s happening
  1. addItem takes Readonly<Cart>, which tells callers "I won't change your cart" — the style React and Redux expect, where a new state object is returned instead.
  2. The assignment to cart.coupon fails (TS2540): coupon is a top-level property, so Readonly covers it.
  3. The correct body builds a new object: copy cart, then replace items with a new array containing the old items plus the new one. The caller's cart is untouched.
  4. addItemSneaky compiles anyway. Readonly<Cart> made the property items read-only — you can't do cart.items = [] — but the array it points to is still a mutable CartItem[], and push is a method call on that array, not an assignment to items.
  5. That's the shallowness in practice. To close it, declare items: readonly CartItem[] in the interface (then push doesn't exist on the type), or use a recursive DeepReadonly.
AdvancedHow Readonly is built

How it's built

lib.es5.d.ts
/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};
What’s happening
  1. For Readonly<Person>, the loop visits "name" and "age".
  2. Each output property gets the readonly modifier in front and keeps its original type: readonly name: Person["name"] → readonly name: string, then readonly age: number.
  3. Optionality is preserved (homomorphic mapping), so coupon?: string becomes readonly coupon?: string.
  4. Applied to an array type, a homomorphic mapped type produces a readonly array: Readonly<string[]> is exactly readonly string[] (see Arrays and readonly arrays).
  5. The value type T[P] is copied unchanged — which is exactly why nested objects stay mutable.
AdvancedReadonly vs Object.freeze at runtime

Compile time only — and Object.freeze

readonly is erased like every other type, so it can't stop JavaScript code, any, or a library from mutating the object. If you need the object to actually refuse writes at runtime, use Object.freeze — whose lib signature conveniently returns Readonly<T>, so you get both:

TypeScript
const config = Object.freeze({ apiUrl: "/v1", retries: 3 });
// const config: Readonly<{ apiUrl: "/v1"; retries: 3 }>

config.retries = 5; // ❌ TS2540: Cannot assign to 'retries' because it is a read-only property.
(config as { retries: number }).retries = 5; // compiles — and throws a TypeError at runtime in strict-mode code
What’s happening
  1. lib.es5.d.ts declares Object.freeze with overloads that all return Readonly<T>, so config is typed Readonly<…> with no annotation from you. The overload for plain objects also infers primitive values as literal types ("/v1", 3) — sensible, since a frozen value can never change.
  2. The direct assignment is a compile error, as with any Readonly type.
  3. The assertion throws the type information away, so the compiler allows the write. At runtime the object really is frozen: in a module (always strict) the assignment throws TypeError: Cannot assign to read only property 'retries' of object.
  4. So: Readonly<T> is a promise checked by the compiler; Object.freeze is enforcement by the engine. Both are shallow.
Can you get around Readonly without a cast?
TypeScript
interface User {
  name: string;
}

const locked: Readonly<User> = { name: "Rohit" };
const unlocked: User = locked;
unlocked.name = "changed";
console.log(locked.name);
Show answer

Yes. It compiles with no errors and prints changed.

What’s happening
  1. locked is { readonly name: string }. Writing through locked would be TS2540.
  2. const unlocked: User = locked asks "is Readonly<User> assignable to User?". TypeScript ignores readonly on properties when checking assignability — it was added to the language late, and making it strict would have broken a huge amount of existing code. So the answer is yes.
  3. unlocked and locked are two names for the same object. Through unlocked the property is writable, so name goes "Rohit" → "changed".
  4. Reading locked.name therefore prints "changed" — the "read-only" view was mutated through a mutable alias.
  5. Arrays are the exception: readonly string[] is not assignable to string[] (TS4104, "The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'"). Readonly arrays are a different type with no push, while readonly properties are just a flag.

#Pick<T, K>

Pick<T, K> builds a type with only the properties of T whose keys are in K. It exists for every "a view of this entity" type: the summary shown in a list, the DTO sent over the wire, the subset of props a child component needs, the columns you SELECT. You pick from the source type instead of re-declaring fields, so the types stay in sync.

The mental model is SELECT name, email FROM users — you list the columns you want, and the result has exactly those, with the same types they have in the table.

From my notes:

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

const newPerson: Pick<Person, "name"> = { name: "hi" };
const tooMuch: Pick<Person, "name"> = { name: "hi", age: 3 }; // ❌ TS2353: Object literal may only specify known properties, and 'age' does not exist in type 'Pick<Person, "name">'.
What’s happening
  1. Pick<Person, "name"> evaluates to { name: string } — age isn't selected, so it isn't part of the type.
  2. newPerson matches exactly.
  3. tooMuch is an object literal with a property the target type doesn't have, so excess property checking flags age (TS2353).
AdvancedPick for DTOs and views

The real-world use: DTOs and views

TypeScript
interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

type UserSummary = Pick<User, "id" | "name">;
// type UserSummary = { id: number; name: string }

function toSummary(user: User): UserSummary {
  return { id: user.id, name: user.name };
}

function toSummaryLeaky(user: User): UserSummary {
  return user; // compiles — but the returned object still carries passwordHash
}

type Broken = Pick<User, "nmae">; // ❌ TS2344: Type '"nmae"' does not satisfy the constraint 'keyof User'.
What’s happening
  1. UserSummary has just id and name, typed from User. If id changes from number to string in User, UserSummary follows.
  2. toSummary builds a new object with only those two properties — the safe version: the response really contains two fields.
  3. toSummaryLeaky also compiles. TypeScript is structural: a User has id and name, so it is a valid UserSummary (excess property checks only apply to fresh object literals, not to existing variables). But the value returned is the full User object, and JSON.stringify will happily serialise passwordHash.
  4. That's the key lesson: Pick narrows the type, not the object. If the extra fields must not leave the server, copy the fields you want (or use a runtime pick, below).
  5. Pick<User, "nmae"> fails because Pick constrains its key argument to keyof T — typos in the key list are caught. (Omit does not do this; see the next topic.)

A runtime pick helper whose return type is exactly a Pick:

TypeScript
function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const key of keys) {
    out[key] = obj[key];
  }
  return out;
}

const user = { id: 1, name: "Rohit", email: "rohit@example.com", passwordHash: "x1y2" };
const summary = pick(user, ["id", "name"]);
// const summary: Pick<{ id: number; name: string; email: string; passwordHash: string }, "id" | "name">

summary.name;
summary.email; // ❌ TS2339: Property 'email' does not exist on type 'Pick<{ id: number; name: string; email: string; passwordHash: string; }, "id" | "name">'.
What’s happening
  1. Calling pick(user, ["id", "name"]) makes the compiler infer T from user and K from the array elements. Because K is constrained to keyof T, it infers the literal union "id" | "name" rather than widening to string.
  2. {} as Pick<T, K> is needed because an empty object isn't a Pick<T, K> yet; the assertion says "trust me, the loop fills it in". This is the one unchecked spot in the function.
  3. In the loop, key has type K. obj[key] is T[K], and out[key] is Pick<T, K>[K], which is also T[K], so the assignment type-checks.
  4. At runtime only id and name are copied — unlike toSummaryLeaky, the object really has two properties: { id: 1, name: "Rohit" }.
  5. The return type is Pick<…, "id" | "name">, so summary.email is an error (TS2339) even though email existed on the input.
AdvancedHow Pick is built

How it's built

lib.es5.d.ts
/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
What’s happening
  1. K extends keyof T is a generic constraint: every key you pass must be a real key of T. That's where the TS2344 for "nmae" comes from.
  2. For Pick<User, "id" | "name">, the loop runs over K, not over all of keyof T. P = "id" gives id: User["id"] → id: number.
  3. P = "name" gives name: string. email, passwordHash and createdAt are never visited, so they're not in the result.
  4. Because K is declared as extends keyof T, the compiler treats Pick as homomorphic and keeps modifiers: picking from { readonly id?: number } gives { readonly id?: number }, not a plain id: number.

#Omit<T, K>

Omit<T, K> is the mirror image of Pick: all the properties of T except the ones in K. You reach for it when the list of things to drop is shorter than the list to keep: the "create" input that has everything except the server-generated id and createdAt, props passed through to a child minus the ones you handle yourself, a public type minus password.

Same SELECT analogy, but written as SELECT * EXCEPT (password).

From my notes:

TypeScript
interface Person {
  name: string;
  age: number;
  address: string;
}

const person: Omit<Person, "address"> = { name: "John", age: 30 };
What’s happening
  1. Omit<Person, "address"> evaluates to { name: string; age: number }.
  2. person has exactly those two properties, so it compiles. Adding address: "…" to the literal would now be an excess property error.
AdvancedOmit for create inputs

The real-world use: create inputs

TypeScript
interface Post {
  id: string;
  createdAt: Date;
  title: string;
  body: string;
  tags: string[];
}

type NewPost = Omit<Post, "id" | "createdAt">;
// type NewPost = { title: string; body: string; tags: string[] }

function createPost(input: NewPost): Post {
  return { ...input, id: crypto.randomUUID(), createdAt: new Date() };
}

createPost({ title: "Utility types", body: "…", tags: ["ts"] });
What’s happening
  1. NewPost is "a post without the fields the server generates". The caller can't supply an id, and doesn't have to.
  2. createPost spreads the caller's three fields and adds the two generated ones. The object literal now has all five properties, so it satisfies the Post return type.
  3. If Post gains a summary: string field, NewPost gains it automatically, and the call at the bottom stops compiling until callers supply it — exactly what you want.
AdvancedHow Omit is built

How it's built ("how Omit conceptually works")

lib.es5.d.ts
/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Omit isn't a new mapped type — it's a composition of two other utilities. Evaluated for Omit<Person, "address">:

What’s happening
  1. keyof T → keyof Person → "name" | "age" | "address".
  2. Exclude<"name" | "age" | "address", "address"> removes the union members assignable to "address" (see Exclude): "name" stays, "age" stays, "address" is dropped → "name" | "age".
  3. Pick<Person, "name" | "age"> builds { name: string; age: number }.
  4. So Omit = "compute the keys to keep with Exclude, then Pick them". Because it ends in Pick, modifiers are preserved, like Pick. Writing your own version (including a mapped-type-only one with key remapping) is in Rebuilding Pick, Required and Omit.
  5. Notice the constraint: K extends keyof any — that's string | number | symbol (see PropertyKey and keyof any), not keyof T. That one detail causes both gotchas below.
AdvancedGotcha: Omit doesn't check keys

Gotcha 1: Omit doesn't check the keys

TypeScript
interface User {
  id: number;
  name: string;
  password: string;
}

type SafeUser = Omit<User, "pasword">; // typo — no error!
// type SafeUser = { id: number; name: string; password: string }

type StrictOmit<T, K extends keyof T> = Omit<T, K>;
type SafeUser2 = StrictOmit<User, "pasword">; // ❌ TS2344: Type '"pasword"' does not satisfy the constraint 'keyof User'.
type SafeUser3 = StrictOmit<User, "password">; // { id: number; name: string }
What’s happening
  1. "pasword" is a string, so it satisfies K extends keyof any and the compiler accepts it.
  2. Exclude<"id" | "name" | "password", "pasword"> removes nothing — no key equals "pasword" — so Pick keeps all three keys. SafeUser still contains password, which is the exact opposite of what the name promises.
  3. StrictOmit re-declares the constraint as K extends keyof T, then delegates to Omit. Now a key that isn't in User is rejected with TS2344.
  4. With a correct key, StrictOmit behaves like Omit. Many codebases (and libraries like type-fest, as Except) define this helper for exactly this reason.
AdvancedGotcha: Omit collapses unions

Gotcha 2: Omit collapses unions

Omit does work on a union type — it compiles — but the result is probably not what you expect.

What is OmitResult?
TypeScript
type Shape =
  | { kind: "circle"; radius: number; id: string }
  | { kind: "square"; size: number; id: string };

type OmitResult = Omit<Shape, "id">;
Show answer

{ kind: "circle" | "square" } — radius and size are gone, and it's no longer a discriminated union.

What’s happening
  1. Omit expands to Pick<Shape, Exclude<keyof Shape, "id">>.
  2. keyof on a union gives only the keys common to every member (a key you can safely read on any value of the union): "kind" | "id". radius and size each exist in only one member, so they drop out right here.
  3. Exclude<"kind" | "id", "id"> → "kind".
  4. Pick<Shape, "kind"> maps over "kind" and reads Shape["kind"], which is "circle" | "square". Result: { kind: "circle" | "square" } — one flat object type.
  5. The root cause is that Omit isn't distributive: it treats the union as one type instead of running once per member. The fix (below) forces distribution.
AdvancedA distributive Omit for unions
TypeScript
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type Shape =
  | { kind: "circle"; radius: number; id: string }
  | { kind: "square"; size: number; id: string };

type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;

type ShapeInput = DistributiveOmit<Shape, "id">;
// type ShapeInput = Omit<{ kind: "circle"; radius: number; id: string }, "id">
//                 | Omit<{ kind: "square"; size: number; id: string }, "id">

const ok: Equal<ShapeInput, { kind: "circle"; radius: number } | { kind: "square"; size: number }> = true;

function area(shape: ShapeInput): number {
  return shape.kind === "circle" ? Math.PI * shape.radius ** 2 : shape.size ** 2;
}
What’s happening
  1. T extends unknown ? … : never looks like a no-op (everything extends unknown), but a conditional type whose checked type is a bare type parameter is distributive: when T is a union, the checker evaluates the body once per member and unions the results (see Distributive conditional types).
  2. Member 1: Omit<{ kind: "circle"; radius: number; id: string }, "id"> → { kind: "circle"; radius: number }. Here keyof sees all three keys, because it's a single object type.
  3. Member 2: → { kind: "square"; size: number }.
  4. The results are unioned, so ShapeInput is still a discriminated union — the Equal assertion confirms it.
  5. That's why area can narrow on shape.kind and then read radius or size. With the plain Omit result, shape.radius would be an error. Use DistributiveOmit (or DistributivePick, same trick) whenever the input might be a union — React prop types for polymorphic components are the classic case.

#Record<K, V>

Record<K, V> builds an object type whose keys are K and whose values are all V. My notes call it a "blueprint for your object" — unlike Pick and Omit, it doesn't start from an existing object type; it creates one from a set of keys. Two very different things come out of it depending on K:

  • K is a union of literals ("admin" | "editor"): a fixed object with exactly those keys, all required. Perfect for lookup tables keyed by a union — and the compiler forces you to cover every member.
  • K is string (or number): an open dictionary — an index signature in disguise.
TypeScript
type Status = "idle" | "loading" | "success" | "error";

const statusLabel: Record<Status, string> = {
  idle: "Not started",
  loading: "Loading…",
  success: "Done",
  error: "Something went wrong",
};

const incomplete: Record<Status, string> = { // ❌ TS2741: Property 'error' is missing in type '{ idle: string; loading: string; success: string; }' but required in type 'Record<Status, string>'.
  idle: "Not started",
  loading: "Loading…",
  success: "Done",
};

function label(status: Status): string {
  return statusLabel[status];
}
What’s happening
  1. Record<Status, string> evaluates to { idle: string; loading: string; success: string; error: string } — one required property per union member.
  2. statusLabel covers all four, so it compiles, and statusLabel[status] in label is typed string with no undefined — every possible key is guaranteed to exist.
  3. incomplete forgets error and gets TS2741. This is the real value: when someone adds "cancelled" to Status, every Record<Status, …> table in the codebase becomes a compile error until it's handled. It's the object-literal version of an exhaustive switch.
  4. Compared with a switch in label, the table is data: easy to scan, easy to test, and the same table can drive a dropdown, a legend and a badge.
AdvancedRecord with string keys as a dictionary

Record with string keys: a dictionary

TypeScript
interface User {
  id: string;
  name: string;
}

const usersById: Record<string, User> = {};
usersById["u1"] = { id: "u1", name: "Rohit" };

const maybe = usersById["does-not-exist"];
// const maybe: User   ← the type says User, the value is undefined

const sparse: Partial<Record<string, User>> = usersById;
const safer = sparse["does-not-exist"];
// const safer: User | undefined

const byParity = Object.groupBy([1, 2, 3, 4, 5], (n) => (n % 2 === 0 ? "even" : "odd"));
// const byParity: Partial<Record<"even" | "odd", number[]>>
What’s happening
  1. Record<string, User> is { [x: string]: User }: any string key, every value a User. That's the right type for a dictionary built at runtime.
  2. The trap: reading a key that isn't there is typed User, not User | undefined. With an index signature the compiler can't know which keys exist, and by default it assumes they all do. maybe.name would compile and crash.
  3. Two fixes: turn on noUncheckedIndexedAccess (every index read gets | undefined), or type the dictionary as Partial<Record<string, User>>, which makes each value optional — safer is User | undefined and you're forced to check. A Map<string, User> is the other honest option: map.get() returns User | undefined.
  4. The standard library uses exactly this combination: Object.groupBy (ES2024) is declared as returning Partial<Record<K, T[]>>. The keys are a literal union here, but a group may be empty, so each one is optional — byParity.even is number[] | undefined.
AdvancedHow Record is built

How it's built

lib.es5.d.ts
/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};
What’s happening
  1. K extends keyof any means K must be something that can be a property key: string | number | symbol or any subset of those.
  2. For Record<Status, string>, the loop runs over "idle" | "loading" | "success" | "error". Each iteration emits P: T — the value type doesn't depend on the key — giving idle: string, loading: string, success: string, error: string.
  3. For Record<string, User>, the loop runs over string. A mapped type over a non-literal key type produces an index signature: { [x: string]: User }.
  4. Unlike Partial or Pick, Record isn't homomorphic — there's no source object whose ? or readonly could be copied — so every property comes out required and mutable. Wrap it (Partial<Record<…>>, Readonly<Record<…>>) when you want modifiers. More on looping over a literal union in Mapping over a literal union.
AdvancedRecord vs a normal interface

Use Record when the keys come from a union or are only known at runtime and all values share one type. Use an interface (or object type) when the keys are fixed names with different value types — { id: number; name: string; tags: string[] } isn't a Record of anything. For a fixed set of keys with the same value type, both work; Record<Status, string> wins because it's tied to Status.

#Exclude and Extract

Exclude<T, U> and Extract<T, U> filter the members of a union. Exclude keeps the members of T that are not assignable to U; Extract keeps the ones that are. They exist because unions are how TypeScript models "one of these", and you constantly need a sub-set: the statuses that are finished, the events that carry a payload, the members of a union that are functions.

The mental model is Array.prototype.filter over a union's members, with "is assignable to U" as the predicate: Extract is filter(m => m extends U), Exclude is filter(m => !(m extends U)). As my notes shout: Exclude is not Omit. Exclude works on the members of a union; Omit works on the keys of an object type.

TypeScript
type Status = "idle" | "loading" | "success" | "error";

type Settled = Exclude<Status, "idle" | "loading">;
// type Settled = "success" | "error"

type InFlight = Extract<Status, "idle" | "loading">;
// type InFlight = "idle" | "loading"

type Primitive = Exclude<string | number | (() => void) | Date, Function | object>;
// type Primitive = string | number
AdvancedHow Exclude and Extract are built

How they're built

lib.es5.d.ts
/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

Evaluating Exclude<Status, "idle" | "loading">:

What’s happening
  1. T is a bare type parameter in the extends check, so the conditional type is distributive: with T = "idle" | "loading" | "success" | "error", the checker runs it once per member and unions the results.
  2. "idle" extends "idle" | "loading" → true → never. "loading" → true → never.
  3. "success" extends "idle" | "loading" → false → "success". "error" → false → "error".
  4. The union is never | never | "success" | "error". never is the empty union, so it disappears: "success" | "error". Returning never is how a conditional type deletes a member.
  5. Extract is the same loop with the branches swapped, so the same input gives "idle" | "loading". And in Primitive, () => void is assignable to Function and Date to object, so those two are removed. How distribution works in general is covered in Distributive conditional types.
AdvancedPicking discriminated union members

The real-world use: picking members of a discriminated union

TypeScript
type AppEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; offset: number };

type ClickEvent = Extract<AppEvent, { type: "click" }>;
// type ClickEvent = { type: "click"; x: number; y: number }

type NonClickEvent = Exclude<AppEvent, { type: "click" }>;
// type NonClickEvent = { type: "keypress"; key: string } | { type: "scroll"; offset: number }

function on<T extends AppEvent["type"]>(type: T, handler: (event: Extract<AppEvent, { type: T }>) => void): void {
  // register the handler…
}

on("keypress", (event) => console.log(event.key));
on("scroll", (event) => console.log(event.key)); // ❌ TS2339: Property 'key' does not exist on type '{ type: "scroll"; offset: number; }'.
What’s happening
  1. Extract<AppEvent, { type: "click" }> checks each member against the shape { type: "click" }. Only the first member has type: "click", so it's the only one assignable (extra properties like x are fine — assignability is structural). Result: the full click event type.
  2. Exclude with the same U keeps the other two members.
  3. In on, AppEvent["type"] is "click" | "keypress" | "scroll", so T is constrained to a valid event name. For on("keypress", …), T is inferred as "keypress".
  4. The handler's parameter is Extract<AppEvent, { type: "keypress" }>, i.e. { type: "keypress"; key: string }, so event.key is a string.
  5. For "scroll" the parameter is the scroll event, which has no key: TS2339. One generic signature gives every event name its own correctly typed handler — the pattern behind typed addEventListener maps and event emitters.

#NonNullable<T>

NonNullable<T> removes null and undefined from a type. It exists because optional and nullable values are everywhere — optional properties, find() results, Map.get(), API fields that may be null — and you often need the type of the value once you know it's there: the element type after filtering out the gaps, the type of an optional property's contents, the return type of an "assert it's present" helper.

TypeScript
interface User {
  id: number;
  manager?: { id: number; name: string } | null;
}

type Manager = NonNullable<User["manager"]>;
// type Manager = { id: number; name: string }

function assertPresent<T>(value: T, message: string): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error(message);
  }
  return value;
}

declare const user: User;
const manager = assertPresent(user.manager, "user has no manager");
// const manager: { id: number; name: string }

const ids = [1, null, 3, undefined].filter((id) => id != null);
// const ids: number[]
What’s happening
  1. User["manager"] is { id: number; name: string } | null | undefined — the undefined comes from the ?. NonNullable strips the last two, so Manager is just the object type, ready to use for a ManagerCard component's props.
  2. assertPresent is generic. Inside, after the if that throws on null/undefined, the compiler narrows value from T to T & {} — which is exactly what NonNullable<T> is defined as (below), so return value type-checks without a cast.
  3. Calling it with user.manager infers T = { id: number; name: string } | null | undefined, and the return type NonNullable<T> is the plain object type.
  4. The filter line uses no utility type at all: since TypeScript 5.5 the compiler infers that id => id != null is a type predicate, so the result is number[]. Before 5.5 you'd write .filter((id): id is NonNullable<typeof id> => id != null) — the place most people first met NonNullable.
AdvancedHow NonNullable is built

How it's built

lib.es5.d.ts
/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T & {};
What’s happening
  1. {} is the type of "any value except null and undefined" — strings, numbers, objects, functions all qualify, because {} has no required members.
  2. Intersecting a union with something distributes over the members: (string | null | undefined) & {} becomes (string & {}) | (null & {}) | (undefined & {}).
  3. string & {} simplifies to string (every string is already a non-nullish value). null & {} and undefined & {} have no values in common, so each reduces to never.
  4. string | never | never is string.
  5. For a generic T the intersection just stays as T & {} — which is why narrowing a T with != null produces T & {}, and why assertPresent above needed no cast.

My notes have a custom implementation — the classic one, and what lib.es5.d.ts itself shipped before TypeScript 4.8:

TypeScript
type MyNonNullable<T> = T extends null | undefined ? never : T;

type A = MyNonNullable<string | null | undefined>; // string
What’s happening
  1. This is a distributive conditional type, exactly like Exclude: it's Exclude<T, null | undefined>.
  2. For string | null | undefined: string → keep; null → never; undefined → never. Result string.
  3. For concrete types both versions give the same answer. The difference is generic code: a conditional type on an unresolved T is deferred — the compiler can't simplify MyNonNullable<T>, so in assertPresent the narrowed value (a T & {}) wouldn't be recognised as assignable to it.
AdvancedEdge case: nested nulls survive

#ReturnType and Parameters

ReturnType<F> gives you the type a function returns, and Parameters<F> gives you its parameter list as a tuple. They exist so that a function can be the single source of truth for its own types. When the function is the thing you control (or the only thing a library exports), you derive the types from it instead of writing an interface by hand that can drift.

Both take a function type, not a function — so in practice they're almost always paired with typeof in type positions: ReturnType<typeof createStore>.

TypeScript
function createStore(initial: number) {
  let count = initial;
  return {
    get: () => count,
    increment: (by = 1) => (count += by),
    reset: () => {
      count = initial;
    },
  };
}

type Store = ReturnType<typeof createStore>;
// type Store = { get: () => number; increment: (by?: number) => number; reset: () => void }

function logStore(store: Store): void {
  console.log(store.get());
}

type Wrong = ReturnType<createStore>; // ❌ TS2749: 'createStore' refers to a value, but is being used as a type here. Did you mean 'typeof createStore'?
What’s happening
  1. createStore has no annotated return type; the compiler infers it from the returned object literal.
  2. typeof createStore (in a type position) is the function's type: (initial: number) => { get: … }.
  3. ReturnType of that is the object type. Store is now a named type you can use in logStore's parameter — and if createStore gains a decrement method, Store gains it too, with no second definition to update.
  4. ReturnType<createStore> fails with TS2749: createStore is a value (a function), and type arguments must be types. The message even suggests the typeof fix.
AdvancedParameters for wrapping a function

Parameters, and wrapping a function

TypeScript
function sendEmail(to: string, subject: string, options?: { cc?: string[] }): boolean {
  return true;
}

type SendEmailArgs = Parameters<typeof sendEmail>;
// type SendEmailArgs = [to: string, subject: string, options?: { cc?: string[] } | undefined]

type EmailOptions = Parameters<typeof sendEmail>[2];
// type EmailOptions = { cc?: string[] } | undefined

function withLogging<F extends (...args: any[]) => any>(fn: F) {
  return (...args: Parameters<F>): ReturnType<F> => {
    console.log(`calling ${fn.name} with`, args);
    return fn(...args);
  };
}

const loggedSend = withLogging(sendEmail);
loggedSend("a@example.com", "Hi");
loggedSend("a@example.com"); // ❌ TS2554: Expected 2-3 arguments, but got 1.
What’s happening
  1. Parameters<typeof sendEmail> is a labelled tuple: the names to, subject, options are kept, so hovers and error messages stay readable, and the ? on options is kept as an optional tuple element.
  2. Indexing a tuple with a number gives one element's type, so [2] pulls out the options type — handy when a library exports the function but not its options interface.
  3. withLogging accepts any function F. The wrapper it returns declares its rest parameter as Parameters<F> and its return as ReturnType<F>, so it has exactly the same signature as the function it wraps.
  4. For withLogging(sendEmail), F is inferred as typeof sendEmail, so loggedSend takes (to, subject, options?) and returns boolean.
  5. Calling it with one argument fails just as calling sendEmail would (TS2554). This "same signature as the wrapped function" pattern is how decorators, memoizers, retry and logging wrappers stay type-safe.
AdvancedHow ReturnType and Parameters are built

How they're built

lib.es5.d.ts
/**
 * Obtain the parameters of a function type in a tuple
 */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
What’s happening
  1. Both constrain T to "some function type" with (...args: any) => any, so ReturnType<string> is a compile error rather than a silent never.
  2. ReturnType: the checker matches T against the pattern (...args: any) => infer R. For typeof createStore the pattern fits, and infer R captures whatever sits in the return position — the store object type. The true branch returns R.
  3. Parameters: same idea with the hole in the parameter position. (...args: infer P) captures the whole parameter list as a tuple, P = [to: string, subject: string, options?: …].
  4. infer declares a type variable that the pattern match fills in — like a capture group in a regular expression. It only works inside the extends clause of a conditional type; see infer: pattern matching on types, where ReturnType is rebuilt from scratch.
  5. The false branches (any, never) are practically unreachable because of the constraint; they're there because a conditional type needs two branches.
Overloads and generics
TypeScript
declare function parse(input: string): number;
declare function parse(input: number): string;

function identity<T>(value: T): T {
  return value;
}

type R1 = ReturnType<typeof parse>;
type R2 = ReturnType<typeof identity>;
Show answer

R1 is string and R2 is unknown.

What’s happening
  1. typeof parse has two call signatures. When infer matches against an overloaded function type, it uses the last signature (usually the most general one), not all of them and not the "best" one.
  2. The last overload is (input: number) => string, so R1 is string. Parameters<typeof parse> would likewise be [input: number]. To get a specific overload's types you have to write them out or use a helper that matches several signatures.
  3. typeof identity is the generic signature <T>(value: T) => T. With no call to infer T from, the checker substitutes T's constraint — here the implicit unknown — so R2 is unknown.
  4. If you need "what does identity return for a string", use an instantiation expression: ReturnType<typeof identity<string>> is string (TypeScript 4.7+).

#ConstructorParameters and InstanceType

These are Parameters and ReturnType for classes. ConstructorParameters<C> is the tuple of arguments new C(…) takes; InstanceType<C> is the type of the object new C(…) produces. They exist for code that receives a class as a value — factories, dependency-injection containers, plugin registries, mixins — where you're holding the constructor, not an instance, and need the types on both sides of new.

The thing to keep straight: a class declaration creates two names. User in a type position means the instance type; typeof User is the constructor — the function you call with new, plus its static members. These utilities take the constructor (typeof User) and get back to the pieces.

TypeScript
class HttpClient {
  constructor(
    public baseUrl: string,
    public timeoutMs = 5000,
  ) {}

  get(path: string): string {
    return `${this.baseUrl}${path}`;
  }
}

type ClientArgs = ConstructorParameters<typeof HttpClient>;
// type ClientArgs = [baseUrl: string, timeoutMs?: number]

type Client = InstanceType<typeof HttpClient>;
// type Client = HttpClient

function create<C extends new (...args: any[]) => any>(Ctor: C, ...args: ConstructorParameters<C>): InstanceType<C> {
  return new Ctor(...args);
}

const client = create(HttpClient, "https://api.example.com");
client.get("/users");
create(HttpClient, 42); // ❌ TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
What’s happening
  1. typeof HttpClient is the constructor type new (baseUrl: string, timeoutMs?: number) => HttpClient. The default value = 5000 makes the parameter optional in the signature.
  2. ConstructorParameters pulls out the new parameters as a labelled tuple; InstanceType pulls out what new returns, which is just HttpClient — for a plain class you'd normally write HttpClient directly.
  3. create is where they earn their keep: it receives some class C, requires the rest arguments to match that class's constructor, and promises to return an instance of that class.
  4. create(HttpClient, "https://api.example.com") infers C = typeof HttpClient, checks "https://…" against baseUrl: string, and returns HttpClient — so client.get is available.
  5. create(HttpClient, 42) fails because 42 doesn't match the first constructor parameter (TS2345). A DI container's container.resolve(SomeService) is typed exactly this way.
AdvancedDeriving instances from a class registry

A registry is the other common case — a map of classes, from which you derive the map of instances:

TypeScript
class Logger {
  log(message: string): void {}
}

class Cache {
  get(key: string): string | undefined {
    return undefined;
  }
}

const services = { logger: Logger, cache: Cache };

type ServiceInstances = { [K in keyof typeof services]: InstanceType<(typeof services)[K]> };
// type ServiceInstances = { logger: Logger; cache: Cache }

abstract class Shape {
  abstract area(): number;
}
type ShapeInstance = InstanceType<typeof Shape>; // Shape — works on abstract classes too
type ErrorArgs = ConstructorParameters<typeof Error>; // [message?: string, options?: ErrorOptions]
What’s happening
  1. services holds constructors (values), so typeof services is { logger: typeof Logger; cache: typeof Cache }.
  2. The mapped type loops over its keys: for K = "logger", (typeof services)["logger"] is typeof Logger, and InstanceType of that is Logger. Same for cache. Add a class to services and ServiceInstances grows automatically.
  3. InstanceType<typeof Shape> works even though Shape is abstract and can't be new-ed: the definitions use an abstract new signature, which accepts both concrete and abstract constructors.
  4. ConstructorParameters<typeof Error> reads the built-in ErrorConstructor: [message?: string, options?: ErrorOptions], where options (with cause) comes from the ES2022 lib.
AdvancedHow ConstructorParameters and InstanceType are built

How they're built

lib.es5.d.ts
/**
 * Obtain the parameters of a constructor function type in a tuple
 */
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;

/**
 * Obtain the return type of a constructor function type
 */
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
What’s happening
  1. These are Parameters and ReturnType with new added to the pattern: abstract new (...args: infer P) => any matches a construct signature instead of a call signature.
  2. For typeof HttpClient, infer P captures [baseUrl: string, timeoutMs?: number] and infer R captures HttpClient.
  3. abstract new is the widest construct-signature type: a concrete class's new (…) => X is assignable to it, and so is an abstract class's. Without abstract, InstanceType<typeof Shape> would be a constraint error.
  4. Passing a plain function type fails the constraint: InstanceType<typeof createStore> is an error, because createStore can't be called with new.

#Awaited<T>

Awaited<T> is the type you get from await-ing a T: it unwraps a promise, and keeps unwrapping if the promise resolves to another promise. It exists because async functions return Promise<X>, while the code that uses them almost always wants X — and because await itself flattens nested promises, which a naive one-level unwrap gets wrong. Added in TypeScript 4.5, it's what the compiler uses to type await expressions and Promise.all.

The most common use is with a function you didn't annotate — the "real Awaited example" from my notes:

TypeScript
async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data: { id: number; name: string; email: string } = await response.json();
  return data;
}

type FetchUserReturn = ReturnType<typeof fetchUser>;
// type FetchUserReturn = Promise<{ id: number; name: string; email: string }>

type User = Awaited<ReturnType<typeof fetchUser>>;
// type User = { id: number; name: string; email: string }

function renderUser(user: User): string {
  return `${user.name} <${user.email}>`;
}
What’s happening
  1. fetchUser is async, so its inferred return type is Promise<{ id: number; name: string; email: string }> — ReturnType alone gives you the promise.
  2. Awaited<…> unwraps it to the object type. Read the whole thing inside-out: "the type of the function → what it returns → what that resolves to".
  3. User is now derived from the fetch function. If the function starts returning more fields, renderUser's parameter type grows with it.
  4. This pair, Awaited<ReturnType<typeof f>>, is the standard way to name the result of an async function, a React Query queryFn, or a Next.js data loader without writing the type twice.
AdvancedAwaited in Promise.all types

Another nice example — the types of Promise.all:

TypeScript
declare function fetchUser(): Promise<{ name: string }>;
declare function fetchPosts(): Promise<string[]>;

async function loadPage() {
  const [user, posts, flag] = await Promise.all([fetchUser(), fetchPosts(), true]);
  // user: { name: string }, posts: string[], flag: boolean
  return { user, posts, flag };
}

type Mixed = Awaited<Promise<string> | number>; // string | number
type Nested = Awaited<Promise<Promise<number>>>; // number
type Plain = Awaited<boolean>; // boolean
What’s happening
  1. Promise.all is declared as all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>. The | [] in the constraint makes T infer as a tuple, [Promise<{ name: string }>, Promise<string[]>, boolean].
  2. The mapped type walks the tuple positions: Awaited<Promise<{ name: string }>> → { name: string }; Awaited<Promise<string[]>> → string[]; Awaited<boolean> → boolean. The -readonly strips any readonly-ness from the input tuple.
  3. await on the resulting promise gives the tuple [{ name: string }, string[], boolean], which destructures into correctly typed user, posts and flag.
  4. Mixed shows Awaited distributing over a union: the promise member is unwrapped, number is left alone. Nested shows the recursion. Plain shows non-promises pass straight through — exactly as await 5 gives 5.
AdvancedHow Awaited is built

How it's built

lib.es5.d.ts
/**
 * Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to `never`. This emulates the behavior of `await`.
 */
type Awaited<T> = T extends null | undefined ? T : // special case for `null | undefined` when not in `--strictNullChecks` mode
    T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ? // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped
        F extends ((value: infer V, ...args: infer _) => any) ? // if the argument to `then` is callable, extracts the first argument
            Awaited<V> : // recursively unwrap the value
        never : // the argument to `then` was not callable
    T; // non-object or non-thenable

Evaluated for Awaited<Promise<Promise<number>>>:

What’s happening
  1. T = Promise<Promise<number>>. Is it null | undefined? No, so move on.
  2. Is it an object with a then method? Yes — this is the thenable check, which is how await decides what to unwrap at runtime too. infer F captures the type of then's first parameter, the onfulfilled callback: (value: Promise<number>) => … (plus null | undefined, because the parameter is optional).
  3. F extends (value: infer V, …) => any matches the callback type and captures its first parameter, V = Promise<number> — the value the promise resolves with. (The null | undefined parts of F go to the never branch and vanish from the union.)
  4. The true branch is Awaited<V> — the type recursively calls itself: Awaited<Promise<number>> runs the same steps and gets Awaited<number>.
  5. number is not an object with then, so the last branch returns T unchanged: number. The recursion stops when there's nothing left to unwrap, just like chained await. Recursive conditional types are covered in Recursive types.

#ThisType<T>

Advanced

ThisType<T> is a marker: it doesn't change the shape of any type. When an object literal is contextually typed by a type that includes ThisType<T>, the compiler uses T as the type of this inside that literal's methods. It exists for "builder" APIs that take an object of methods and later call them with a different this than the literal itself — Vue's Options API, Pinia stores, and older libraries that mix data and methods into one object.

My notes call it crazy, and it is a little: look at its definition — an empty interface. The compiler recognises the name ThisType specially; there's no structure to evaluate.

lib.es5.d.ts
/**
 * Marker for contextual 'this' type
 */
interface ThisType<T> {}

The problem it solves — without it, this is just the object literal:

TypeScript
function defineStoreLoose<S, A>(options: { state: S; actions: A }): S & A {
  return Object.assign({}, options.state, options.actions);
}

const counterLoose = defineStoreLoose({
  state: { count: 0 },
  actions: {
    increment() {
      this.count++; // ❌ TS2339: Property 'count' does not exist on type '{ increment(): void; }'.
    },
  },
});
What’s happening
  1. defineStoreLoose merges state and actions into one object at runtime with Object.assign. When you later call store.increment(), this is the merged object, which does have count.
  2. But the compiler types this in a method from where the method is written: inside an object literal, this is that literal's type — here { increment(): void }, the actions object.
  3. That object has no count, so this.count++ fails with TS2339, even though the code would work at runtime.
  4. You need a way to tell the compiler "inside these methods, this will be state plus actions". That's what ThisType is for.

From my notes, "create a generic blueprint for an object":

TypeScript
type StoreOptions<S, A> = {
  state: S;
  actions: A & ThisType<S & A>;
};

function defineStore<S, A>(options: StoreOptions<S, A>): S & A {
  return Object.assign({}, options.state, options.actions);
}

const counter = defineStore({
  state: { count: 0, step: 1 },
  actions: {
    increment() {
      this.count += this.step;
    },
    incrementTwice() {
      this.increment();
      this.increment();
    },
    reset() {
      this.cuont = 0; // ❌ TS2551: Property 'cuont' does not exist on type '{ count: number; step: number; } & { increment(): void; incrementTwice(): void; reset(): void; }'. Did you mean 'count'?
    },
  },
});

counter.incrementTwice();
console.log(counter.count); // 2
What’s happening
  1. The actions property is typed A & ThisType<S & A>. A is inferred from the methods as usual; ThisType<S & A> adds nothing structurally (it's an empty interface) but marks the literal.
  2. When the compiler checks the methods inside actions, it sees the marker and types this as S & A — here { count: number; step: number } & { increment(): void; incrementTwice(): void; reset(): void }.
  3. So this.count, this.step and this.increment() all type-check, and the typo this.cuont is caught with a "Did you mean" suggestion (TS2551).
  4. At runtime Object.assign copies count, step and the three methods onto one object. counter.incrementTwice() calls increment twice with this === counter, so count goes 0 → 1 → 2.
  5. ThisType only affects this in methods when noImplicitThis is on (it is, under strict). It doesn't check that this really will be S & A at runtime — that's a promise your function (defineStore) has to keep. For this in ordinary functions, see Typing this.

#String manipulation types

Uppercase<S>, Lowercase<S>, Capitalize<S> and Uncapitalize<S> transform string literal types: Capitalize<"click"> is "Click". On their own they're a curiosity; combined with template literal types and key remapping they let you derive names — onClick from click, getName from name, USER_CREATED from userCreated — so the naming convention is enforced by the compiler instead of by hand.

They're the only utility types that are not written in TypeScript. Their definitions are intrinsic, meaning "implemented inside the compiler" (it runs the JavaScript string method on the literal), because there's no type-level way to change a character's case:

lib.es5.d.ts
type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;
AdvancedUppercase, Capitalize and friends
TypeScript
type Method = "get" | "post";

type Upper = Uppercase<Method>;            // "GET" | "POST"
type Title = Capitalize<"hello world">;    // "Hello world"
type Lower = Uncapitalize<"UserId">;       // "userId"
type Handler = `on${Capitalize<"click" | "keydown">}`; // "onClick" | "onKeydown"

const shout: Uppercase<string> = "LOUD";
const quiet: Uppercase<string> = "quiet"; // ❌ TS2322: Type 'string' is not assignable to type 'Uppercase<string>'.
What’s happening
  1. Given a union, they apply to each member: Uppercase<"get" | "post"> → "GET" | "POST".
  2. Capitalize changes only the first character — "hello world" becomes "Hello world", not title case. Uncapitalize lowercases only the first character.
  3. Inside a template literal type, on${Capitalize<"click" | "keydown">} capitalises each member and prefixes it: "onClick" | "onKeydown". Note "onKeydown", not "onKeyDown" — the type knows nothing about word boundaries.
  4. With a non-literal string, the result stays as the type Uppercase<string>: "any string that's already upper-case". "LOUD" passes; "quiet" fails with TS2322, because upper-casing it would change it.
AdvancedDeriving handler and getter names

The real-world use: deriving handler and getter names

From my notes, "string templating and Capitalize":

TypeScript
interface FormEvents {
  submit: { values: Record<string, string> };
  reset: undefined;
  change: { field: string; value: string };
}

type FormHandlers = {
  [K in keyof FormEvents as `on${Capitalize<K>}`]?: (payload: FormEvents[K]) => void;
};
// type FormHandlers = {
//   onSubmit?: (payload: { values: Record<string, string> }) => void;
//   onReset?: (payload: undefined) => void;
//   onChange?: (payload: { field: string; value: string }) => void;
// }

const handlers: FormHandlers = {
  onSubmit: ({ values }) => console.log(values),
  onChange: ({ field, value }) => console.log(field, value),
  onSumbit: () => {}, // ❌ TS2561: Object literal may only specify known properties, but 'onSumbit' does not exist in type 'FormHandlers'. Did you mean to write 'onSubmit'?
};

type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

type PersonGetters = Getters<{ name: string; age: number }>;
// type PersonGetters = { getName: () => string; getAge: () => number }
What’s happening
  1. FormHandlers maps over the keys of FormEvents. For each key K, the as clause renames it: K = "submit" → Capitalize<"submit"> = "Submit" → "onSubmit". Likewise "onReset" and "onChange".
  2. The value side uses the original key: FormEvents["submit"] is the submit payload, so onSubmit's parameter is { values: Record<string, string> }. Destructuring { values } in the handler is fully typed.
  3. A misspelt handler name is caught by excess property checking, with a suggestion (TS2561). Add blur to FormEvents and onBlur appears automatically.
  4. In Getters, K & string is needed because keyof T can include number and symbol keys, and Capitalize only accepts strings. Intersecting with string keeps the string keys (and turns any symbol key into never, which an as clause drops).
  5. Getters<{ name: string; age: number }> gives getName: () => string and getAge: () => number. The renaming syntax is explained in Key remapping with as.

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.