TypeScript From Zero

Utility, Mapped and Conditional Types

Built-in helper types, mapped and conditional types, infer, template literal types, and when not to be clever.

Lesson 0428 min read#typescript#generics#type-level

On this page

  1. Words used in this lesson
  2. Reading types: the Expand helper
  3. The built-in utility types
  4. keyof, indexed access, typeof: a short recap
  5. Mapped types: a loop over keys
  6. Conditional types: an if for types
  7. Template literal types
  8. Building your own helpers, step by step
  9. When NOT to be clever
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: a utility type is a ready-made function from one type to another (Partial<User>, Pick<User, "id">). Mapped and conditional types are how you write your own. Learn the built-in ones first, build small helpers step by step, and stop before the types become harder to read than the code.

Lesson 3 ended with { [K in keyof Events]?: ... } and said "mapped type, later". This is later. We start with the helpers that ship with TypeScript, because they solve most real problems. Then we open them up: a mapped type loops over keys, a conditional type picks one of two results, and infer pulls a piece out of a type. At the end we build a few helpers from nothing and talk about when not to write them. For interview-style questions on the same topics see Full-Stack Q&A, Lesson 3 and Frontend Depth, Lesson 4. If keyof, typeof and T[K] feel new, read the section on them in Lesson 3 first.

What I ran / checked. Everything here was compiled with TypeScript 7.0.2 (tsc --pretty false) on Node v22.22.0, using the tsconfig from Lesson 0 and @types/node 22. The one program that prints something (the getters example) was run with node file.ts. Types have no output, so I reveal a type the way Lesson 1 did: assign it to never, and the error message prints the type. To see the inside of a type and not its alias name, I wrap it in type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;, which is defined in the first section. I did not read the handbook for this lesson, but I did read the Omit, ReturnType and Awaited definitions in the installed lib.es5.d.ts. The behaviour of each helper is what the compiler printed. I did not test other TypeScript versions.

Words used in this lesson

  • Utility type is a generic type that TypeScript already defines for you, such as Partial<T>. It takes a type and gives back a new type.
  • Mapped type is a type that loops over a list of keys and builds an object type: { [K in Keys]: ... }.
  • Modifier is readonly or ? on a property. A mapped type can add one (+) or remove one (-).
  • Key remapping is the as clause inside a mapped type. It changes the name of each key, or drops it.
  • Conditional type is A extends B ? X : Y. It is an if for types.
  • infer declares a new type variable inside the extends part of a conditional type and lets TypeScript fill it in.
  • Distributive means a conditional type is applied to each member of a union separately, and the results are joined again.
  • Template literal type is a string type built with backticks, `on${Event}`. With a union inside, it makes every combination.
  • Recursive type is a type that uses itself in its own definition.
  • never is the empty type. A union with never in it just drops that part ("a" | never is "a").

Reading types: the Expand helper

A type alias keeps its name in messages, so Pick<User, "id"> prints as Pick<User, "id"> and you cannot see the object. I use this tiny helper to open it up:

JavaScript
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;

Do not worry about how it works yet; the lesson explains every piece. It is only a flashlight. We will use it as declare const x: Expand<Something>; const reveal: never = x;, and the error prints the full shape.

The built-in utility types

Take one type to work with:

JavaScript
type User = {  id: number;  name: string;  email?: string;  readonly role: "admin" | "user";};

Types that change properties

UtilityWhat it doesResult for User (as printed by tsc)
Partial<User>Every property becomes optional{ id?: number | undefined; name?: string | undefined; email?: string | undefined; readonly role?: "admin" | "user" | undefined; }
Required<User>Every property becomes required{ id: number; name: string; email: string; readonly role: "admin" | "user"; }
Readonly<User>Every property becomes readonly{ readonly id: number; readonly name: string; readonly email?: string | undefined; readonly role: "admin" | "user"; }
Pick<User, "id" | "name">Keep only these keys{ id: number; name: string; }
Omit<User, "email" | "role">Remove these keys{ id: number; name: string; }
Record<"a" | "b", number>An object with these keys, all with this value type{ a: number; b: number; }

Each row is the output of the Expand flashlight. Two details to notice. Required also removed the | undefined that an optional property carries, and readonly stayed where it was. Then the errors you get when you break the rules:

JavaScript
const r: Required<User> = { id: 1, name: "x", role: "user" };const ro: Readonly<User> = { id: 1, name: "x", role: "user" };ro.name = "y";const pk: Pick<User, "id" | "name"> = { id: 1, name: "a", email: "e" };const rc: Record<"a" | "b", number> = { a: 1 };
TEXT
src/a1.ts(9,7): error TS2741: Property 'email' is missing in type '{ id: number; name: string; role: "user"; }' but required in type 'Required<User>'.src/a1.ts(11,4): error TS2540: Cannot assign to 'name' because it is a read-only property.src/a1.ts(12,36): error TS2353: Object literal may only specify known properties, and 'email' does not exist in type 'Pk'.src/a1.ts(14,7): error TS2741: Property 'b' is missing in type '{ a: number; }' but required in type 'Rec'.

(The names Pk and Rec in the messages are there because in that file I had given the types those alias names.)

Where each one is used in real code:

  • Partial<T> for an update or a patch: you send only the fields that changed.
  • Pick and Omit for a view of a type: the public part of a User without the password hash.
  • Readonly<T> for config you do not want changed after startup. It checks only at compile time; at runtime the object is still a normal object. (Object.freeze is the runtime version.)
  • Record<Keys, V> for a lookup table, such as Record<Role, string[]>. If Keys is a union, every key must be present; a missing one is an error:
JavaScript
type Role = "admin" | "user";const perms: Record<Role, string[]> = { admin: ["all"], user: [] };const bad2: Record<Role, string[]> = { admin: [] };
TEXT
src/d1.ts(26,7): error TS2741: Property 'user' is missing in type '{ admin: never[]; }' but required in type 'Record<Role, string[]>'.

That is a nice property: add a new role to the union and every Record<Role, ...> in the project tells you it needs an entry. With Record<string, V> there is no such check, and with noUncheckedIndexedAccess a read gives V | undefined (I checked: dict["zzz"] was number | undefined).

Types that work on unions

JavaScript
type Ex  = Exclude<"a" | "b" | "c", "a">;type Ext = Extract<string | number | (() => void), Function>;type NN  = NonNullable<string | null | undefined>;

Revealed: Ex is "b" | "c", Ext is () => void, NN is string.

  • Exclude<U, X> removes from the union U every member that is assignable to X.
  • Extract<U, X> keeps only those members.
  • NonNullable<T> removes null and undefined.

They are the union versions of Omit and Pick. Omit works on keys; Exclude works on members of a union. A common use is to take the keys of a type and remove some: Exclude<keyof User, "role">.

Types that work on functions

JavaScript
async function load(id: number, opts?: { cache: boolean }) {  return { id, name: "Ada" };}type Ret = ReturnType<typeof load>;type Par = Parameters<typeof load>;type Aw  = Awaited<ReturnType<typeof load>>;type Aw2 = Awaited<Promise<Promise<string>>>;

Revealed with the never trick:

TEXT
Type 'Promise<{ id: number; name: string; }>' is not assignable to type 'never'.      (Ret)Type '[id: number, opts?: { cache: boolean; } | undefined]' is not assignable to type 'never'.   (Par)Type '{ id: number; name: string; }' is not assignable to type 'never'.               (Aw)Type 'string' is not assignable to type 'never'.                                       (Aw2)
  • You pass the type of the function, so you write typeof load (the value load is not a type).
  • ReturnType of an async function is a Promise. Awaited<...> peels off the Promise layers, and it also works through several layers (Promise<Promise<string>> became string).
  • Parameters gives a tuple with the parameter names and the optional marks. This is how you can write "the same arguments as that other function".
  • ConstructorParameters<typeof Date> and InstanceType<typeof Map<string, number>> do the same for classes.

The functions take a function type only. ReturnType<string> is refused:

TEXT
src/d1.ts(16,23): error TS2344: Type 'string' does not satisfy the constraint '(...args: any) => any'.

Getting a type from a value saves you from writing it twice. ReturnType<typeof makeStore> is better than copying the type of what makeStore returns into a second interface that can drift away from it. The same goes for typeof config and (typeof ROLES)[number]. I checked that const ROLES = ["admin", "user"] as const gives (typeof ROLES)[number] the type "admin" | "user".

A trap: Omit on a union

JavaScript
type Shape = { kind: "c"; r: number } | { kind: "s"; side: number };type O1 = Omit<Shape, "kind">;

Revealed: O1 is {}. Omit is not distributive. keyof Shape is only the keys that both members share (here "kind"), so after removing kind nothing is left. If you need to remove a key from each member, apply Omit to each member (we build DistOmit below, and its result is Omit<{ kind: "c"; r: number; }, "kind"> | Omit<{ kind: "s"; side: number; }, "kind">).

keyof, indexed access, typeof: a short recap

From Lesson 3, in one box, because every helper below uses them:

JavaScript
type Api = { id: number; load(): void; save(x: string): boolean };type K = keyof Api;          // "id" | "load" | "save"type V = Api[keyof Api];     // the union of all property typestype S = Api["save"];        // (x: string) => boolean

keyof T is the union of the key names. T[K] is the type of a property. T[keyof T] is the union of all the value types. They will appear in nearly every line from here on.

Mapped types: a loop over keys

Here is Partial written by hand. It is one line:

JavaScript
type MyPartial<T> = { [K in keyof T]?: T[K] };

Read it as a for loop: "for each key K in keyof T, make a property named K, optional, with the same type as T[K]". Pick is the same idea, with a loop over a list of keys you pass in:

JavaScript
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

I put Api into these and revealed MyPick<Api, "id">: { id: number; }. And MyPartial<Api> = { id: "x" } failed with Type 'string' is not assignable to type 'number', so the property types are still checked.

When the loop runs over keyof T exactly, TypeScript keeps the modifiers (readonly and ?) of the original properties. This is called a homomorphic mapped type. That is why Partial<User> kept readonly role.

Adding and removing modifiers

Write + or - before readonly or ?. Take type U = { readonly a: number; b?: string }:

JavaScript
type Flags<T> = { [K in keyof T]: boolean };type Mut<T>   = { -readonly [K in keyof T]: T[K] };type Req<T>   = { [K in keyof T]-?: T[K] };type Opt<T>   = { [K in keyof T]+?: T[K] };

Revealed:

TEXT
Flags<U>  { readonly a: boolean; b?: boolean | undefined; }Mut<U>    { a: number; b?: string | undefined; }Req<U>    { readonly a: number; b: string; }Opt<U>    { readonly a?: number | undefined; b?: string | undefined; }

Flags kept both modifiers and only changed the values. Mut removed readonly (TypeScript has no built-in for this; it is a handy one to write). Req is the built-in Required, and Opt is Partial.

Another small helper, Nullable, which adds | null to every value. Revealed for { a: number; b?: string }: { a: number | null; b?: string | null | undefined; }.

JavaScript
type Nullable<T> = { [K in keyof T]: T[K] | null };

Key remapping with as

Add as after the key to compute a new name. Use a template literal (see below) to build names:

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

Revealed: { getAge: () => number; getName: () => string; }. (string & K is there because a key can also be a number or a symbol, and Capitalize needs a string.)

If the as clause gives never for a key, that key is dropped. This is how you filter:

JavaScript
type NoId<T> = { [K in keyof T as Exclude<K, "id">]: T[K] };type OnlyStr<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };

Revealed: NoId<{ id: number; name: string }> is { name: string; }, and OnlyStr<{ id: number; name: string; email: string }> is { name: string; email: string; }. The second one uses a conditional type, the next topic.

Here is the getter type in a real, running function:

JavaScript
function withGetters<T extends object>(obj: T): Getters<T> {  const out: Record<string, () => unknown> = {};  for (const key of Object.keys(obj) as (keyof T & string)[]) {    out["get" + key[0]!.toUpperCase() + key.slice(1)] = () => obj[key];  }  return out as Getters<T>;}const person = withGetters({ name: "Ada", age: 36 });console.log(person.getName(), person.getAge() + 1);person.getEmail();
TEXT
src/e2.ts(13,8): error TS2339: Property 'getEmail' does not exist on type 'Getters<{ name: string; age: number; }>'.Ada 37

(The error is from tsc. The Ada 37 line is from node after I deleted the last line.) Notice the honest part: the body of withGetters needs as Getters<T> at the end. TypeScript cannot prove that a loop built an object of a mapped type, so the cast is where you promise it. The callers get a safe type; the one place with the cast must be correct by testing.

Conditional types: an if for types

JavaScript
type IsString<T> = T extends string ? "yes" : "no";

IsString<"a"> is "yes" and IsString<number> is "no" (revealed). T extends X ? A : B means: if T is assignable to X, the result is A, otherwise B.

infer: pull a piece out

infer lets you name a part of a type that you do not know yet:

JavaScript
type Elem<T>     = T extends (infer U)[] ? U : T;type MyReturn<T> = T extends (...args: any[]) => infer R ? R : never;type FirstArg<T> = T extends (a: infer A, ...rest: any[]) => any ? A : never;type Unwrap<T>   = T extends Promise<infer U> ? Unwrap<U> : T;

Revealed: Elem<string[]> is string, MyReturn<() => Date> is Date, FirstArg<(s: string, n: number) => void> is string, and Unwrap<Promise<Promise<number>>> is number.

Read MyReturn as a pattern match: "if T looks like a function that returns something, call that something R and give it back". This is exactly how ReturnType is written. Unwrap calls itself, so it is recursive, and the built-in Awaited (more careful than my version) is also recursive.

Distributive conditional types

This is the rule that surprises people. When the checked type T is a naked type parameter (just T, not T[] or [T]), a union is split, each member is tested alone, and the answers are joined:

JavaScript
type ToArr<T>   = T extends unknown ? T[] : never;type ToArrNo<T> = [T] extends [unknown] ? T[] : never;

Revealed:

TEXT
ToArr<string | number>     string[] | number[]ToArrNo<string | number>   (string | number)[]

Same logic, different answer. Wrapping both sides in [ ] turns the distribution off. Distribution is why Exclude and Extract work: Exclude<"a" | "b" | "c", "a"> tests "a", "b", "c" one at a time and joins the survivors. It also causes a famous trap with never:

JavaScript
type IsNever<T>  = T extends never ? true : false;type IsNever2<T> = [T] extends [never] ? true : false;

IsNever2<never> is true. But IsNever<never> is never, not false: the empty union has no members, so the loop runs zero times. (My reveal for IsNever<never> produced no error at all, because never is assignable to never; that is the clue.) When you want to test for never, wrap in brackets.

Template literal types

A template literal type builds string types, with the same backticks as in JavaScript:

JavaScript
type Evt = "click" | "focus";type Handler = `on${Capitalize<Evt>}`;type Pair = `${"a" | "b"}-${1 | 2}`;const bad: Handler = "onHover";
TEXT
Handler:  "onClick" | "onFocus"Pair:     "a-1" | "a-2" | "b-1" | "b-2"src/c1.ts(32,7): error TS2322: Type '"onHover"' is not assignable to type '"onClick" | "onFocus"'.

A union inside produces every combination (2 x 2 = 4 for Pair). Capitalize, Uncapitalize, Uppercase and Lowercase are built-in string helpers.

Combined with infer, a template literal can take a string apart. This one reads the :name parts out of a route:

JavaScript
type Params<S extends string> =  S extends `${string}:${infer P}/${infer Rest}` ? P | Params<`/${Rest}`>  : S extends `${string}:${infer P}` ? P  : never;

Params<"/users/:id/posts/:postId"> is "id" | "postId". This is the kind of thing routers and query builders do so that req.params.id is typed. It works, and it is also near the limit of what I would write in an app.

Building your own helpers, step by step

Say you want a type for "a User where only email and address are optional". Do not try to write it all at once. Go in small, checkable steps.

Step 1. Say it in words. "Remove these keys, then add them back as optional."

Step 2. Use the built-ins. Omit removes, Partial<Pick<...>> adds back optional, & joins:

JavaScript
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

Step 3. Flatten it so messages are readable, and test it with a real value and a typo:

JavaScript
type User = { id: number; name: string; email: string; address: { city: string; zip?: string } };declare const s1: Expand<PartialBy<User, "email" | "address">>;const nope: PartialBy<User, "nmae"> = ok;
TEXT
Type '{ id: number; name: string; email?: string | undefined; address?: { city: string; zip?: string | undefined; } | undefined; }' is not assignable to type 'never'.src/d1.ts(8,29): error TS2344: Type '"nmae"' does not satisfy the constraint 'keyof User'.

The constraint K extends keyof T is what turns the typo into an error. Without it, Omit would accept any string and the helper would silently do nothing.

A second one, DeepPartial. Partial is only one level deep. To make nested objects optional too, make the mapped type call itself:

JavaScript
type DeepPartial<T> =  T extends (infer U)[] ? DeepPartial<U>[]  : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> }  : T;const patch: DeepPartial<User> = { address: { zip: "1" } };const patchBad: DeepPartial<User> = { address: { zip: 1 } };
TEXT
src/d1.ts(14,50): error TS2322: Type 'number' is not assignable to type 'string'.

The first assignment passes: address is present, but city is not needed. The second fails on the deep zip. Pieces: arrays are handled first (so an array does not turn into an object with number keys), objects recurse, everything else (a string, a number) is returned as it is.

A third one, a distributive Omit, to fix the trap above:

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

The T extends unknown ? is a trick that is always true and exists only to switch distribution on.

A fourth, "the keys whose values are functions":

JavaScript
type FnKeys<T> = {  [K in keyof T]: T[K] extends (...a: any[]) => any ? K : never;}[keyof T];

Build it from the inside out. The mapped type replaces each property by its own key if it is a function, otherwise never. Then [keyof T] reads all the values at once, and never drops out of the union. FnKeys<Api> gave me a type equal to "load" | "save" (I checked both ways with a small Eq test, because tsc prints only the alias name here). This "map, then index with [keyof T]" shape is very common.

How to develop a type. Write a value that should pass and a value that should fail, and leave them in a test file (const ok: MyType = ...; // @ts-expect-error, Lesson 7). Reveal intermediate results with Expand. Build with the built-ins first, and write a mapped or conditional type only when they cannot say it.

When NOT to be clever

Types are code that other people must read and tsc must run. Some rules I follow:

  • Prefer the built-in. Partial<Pick<T, K>> is known by every TypeScript developer. A hand-written version is not.
  • Name the intent. type CreateUserInput = Omit<User, "id" | "createdAt"> says what it is for. A type with five nested conditionals does not.
  • Know where it gets slow or breaks. A type that calls itself without an end stops the compiler:
JavaScript
type Loop<T> = T extends string ? Loop<`${T}a`> : never;type Boom = Loop<"x">;
TEXT
src/e1.ts(2,13): error TS2589: Type instantiation is excessively deep and possibly infinite.
  • Error messages get worse. A user of your clever helper sees a message about your internal types, not about their mistake.
  • A cast inside is fine, if it is tested. As in withGetters, the outside is typed and the inside has one as. That is often a better trade than a type that proves everything.
  • Consider a plain interface. If you write type UserDTO = Omit<User, "password"> & Partial<...> three times, a named interface with the three fields may be clearer. Duplication in types is cheaper than a puzzle.

A type that nobody can read is a bug waiting. If a teammate needs ten minutes to understand a helper, the helper costs more than it saves. Write it, test it, comment one line on what it does and why, and put it in one shared file.

Try it

  1. Write MyPartial, MyPick and Nullable by hand and test each with a good and a bad value.
  2. Write ValueOf<T> = T[keyof T]. What is ValueOf<{ x: 1; y: "z" }>?
  3. Write FnKeys<Api> as above for your own type with two methods and one data field. What does it give?
  4. Write type Elem<T> = T extends (infer U)[] ? U : never and call it with string[] and with string. What do you get for each?
  5. Write type ToArr<T> = T extends unknown ? T[] : never and call it with string | number. Now change it to [T] extends [unknown]. What changes, and why?
  6. Make Getters<T> produce setName methods instead: (v: T[K]) => void.

Answers.

  1. See the definitions above. For Nullable<{ a: number; b?: string }> I got { a: number | null; b?: string | null | undefined; }, and MyPartial<Api> = { id: "x" } failed with Type 'string' is not assignable to type 'number'.
  2. 1 | "z", the union of the values. I checked it with an Eq helper because tsc printed only the alias name.
  3. The names of the two methods, as a union. For Api it was "load" | "save", and the data field id was left out.
  4. string for string[]. For string, the false branch gives never. (I did not run exercise 4 as a separate file; it is the same pattern as Elem above, which gave string.)
  5. With T extends unknown it is distributive and gives string[] | number[]. With [T] extends [unknown] the union is not split and gives (string | number)[].
  6. Change get${...} to set${...} and the value to (v: T[K]) => void. I did not run this one; it is the Getters pattern with a different value.

Interview questions

What is the difference between Pick, Omit, Exclude and Extract? (Must know)

Pick<T, K> and Omit<T, K> work on the keys of an object type: keep these keys, or remove these keys. Exclude<U, X> and Extract<U, X> work on the members of a union: remove or keep the members assignable to X. For example Exclude<"a" | "b" | "c", "a"> is "b" | "c". They are linked: in TypeScript's own lib.es5.d.ts I read type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;.

How would you type a PATCH request body for a User? (Must know)

I use Partial<User> if every field can change. If some must not (the id), I use Partial<Omit<User, "id">>. In my test, Partial kept the original readonly marks and added | undefined to each field. I would also validate the body at runtime, because the type does not check what a client really sends (Lesson 6).

How is Partial<T> implemented? (Must know)

It is a mapped type: { [K in keyof T]?: T[K] }. It loops over the keys of T and makes each one optional with the same value type. Because the loop is over keyof T, it is homomorphic, so it keeps the readonly marks of the original properties. Required is the same with -?, and Readonly adds readonly.

What do + and - do in a mapped type? (Senior signal)

They add or remove a modifier. -readonly removes readonly, -? removes the optional mark, and +? and +readonly add them (the + is the default and can be left out). In my run, Req<U> with -? gave { readonly a: number; b: string; }, with readonly kept. There is no built-in Mutable, so I write { -readonly [K in keyof T]: T[K] } when I need it.

What is key remapping and when do I use it? (Senior signal)

It is the as clause in a mapped type: [K in keyof T as NewName]. I use it to rename keys, such as get${Capitalize<string & K>}, and to filter keys, because if the new name is never the key is dropped. OnlyStr<T> used as T[K] extends string ? K : never to keep only string fields.

What does infer do? Give an example. (Must know)

It declares a type variable inside the extends part of a conditional type, and TypeScript fills it by pattern matching. type MyReturn<T> = T extends (...args: any[]) => infer R ? R : never gives Date for () => Date. It is how ReturnType, Parameters and Awaited are made. If the type does not match the pattern, the false branch is used.

What is a distributive conditional type? (Senior signal)

When the type before extends is a naked type parameter and I pass a union, TypeScript applies the condition to each member and joins the results. ToArr<string | number> with T extends unknown ? T[] : never gave string[] | number[]. If I write [T] extends [unknown], it does not distribute, and I got (string | number)[]. The trap is never: IsNever<never> with the plain form gave never, not false, because there are no members to test. I use [T] extends [never].

Why is Omit a problem on a union type? (Senior signal)

Omit<T, K> uses keyof T, and keyof of a union is only the keys all members share. So Omit<Circle | Square, "kind"> collapsed to {} in my test, and the other fields were lost. I fix it with a distributive helper: T extends unknown ? Omit<T, K> : never, which gives a union of the two reduced objects.

What do ReturnType and Awaited give for an async function? (Must know)

ReturnType<typeof load> is Promise<{ id: number; name: string }>, because an async function always returns a promise. Awaited<ReturnType<typeof load>> is { id: number; name: string }: the result after await. I use this pair to get the type of data without writing it twice. ReturnType works only on function types, and ReturnType<string> gave TS2344.

What are template literal types good for? (Senior signal)

They build and take apart string types. `on${Capitalize<"click" | "focus">}` gives "onClick" | "onFocus", and a union inside makes all combinations. With infer they can read parts of a string, like pulling id and postId out of "/users/:id/posts/:postId". I use them for event names, CSS class names, route params and key names. They get slow and hard to read in long chains, so I keep them small.

When should you not write a clever type? (Senior signal)

When a built-in or a plain interface does the job, when the team cannot read it, or when it needs recursion. My test of a self-calling conditional type ended with TS2589: Type instantiation is excessively deep and possibly infinite. Clever types also make error messages longer. I prefer a simple, named type and one tested as inside a function over a type that proves everything but nobody understands.

How do you test a type you wrote? (Senior signal)

I write values that must compile and values that must fail, and reveal the type with a helper like Expand. For failures I use // @ts-expect-error, which itself becomes an error if the line stops failing (Lesson 7). For exact equality I use a small check such as Eq<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false and assign true to it. I used that to check FnKeys<Api> was exactly "load" | "save".

Key takeaways

  • Utility types: Partial, Required, Readonly, Pick, Omit and Record change object types; Exclude, Extract and NonNullable change unions; ReturnType, Parameters and Awaited read function types.
  • A mapped type is a loop: { [K in keyof T]: ... }. It keeps modifiers when looping over keyof T, and +/- add or remove them.
  • as in a mapped type renames keys, and never drops them.
  • A conditional type is A extends B ? X : Y; infer names a part to extract. A union in a naked type parameter is split (distributive); wrap in [ ] to stop it.
  • Template literal types make string unions, and with infer they parse strings.
  • Build helpers in small steps. Use a flashlight like Expand, and test good and bad values.
  • Stop when the type is harder to read than the code: prefer built-ins, names that show intent, and one tested cast inside.

What's next

Lesson 5: Modules, Declaration Files and tsconfig in Real Projects. Types are only useful when they cross files and packages, so we look at ES modules and CommonJS, the moduleResolution choices, .d.ts files, @types packages, and how the tsconfig differs for a Node app, a Vite or Next.js app and a library.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series