Advanced types
Conditional distribution, infer, mapped and template types, recursion, variance, brands, and type equality.
The idea
File 08 described a type layer that describes shapes. This one gives it the ability to compute, and the shift is bigger than the syntax suggests: you stop annotating and start programming.
The trick to reading any of it is to notice that the pieces map onto things you already know:
| type-level | value-level |
|---|---|
A extends B ? X : Y |
if |
{ [K in keyof T]: … } |
a loop over keys |
infer U |
destructuring — bind a name to a matched part |
`${A}-${B}` |
template strings |
| a type referring to itself | recursion |
It is a small pure, lazy, functional language. Pure and lazy matter practically: nothing is evaluated until something asks, so a type that would be an infinite loop is fine as long as nobody instantiates it that deep.
Two operators bridge the two worlds and everything here is built from
them: typeof moves a value into type space, and
keyof looks inside a type. Indexed access
(T["k"], T[number]) is how you read a part
back out.
Conditional types, and the behaviour that hides
A extends B ? X : Y is the if. Read
extends as “is assignable to”, not “is
exactly” — file 37’s whole subject, arriving early.
Then the behaviour that is invisible until it bites. When the checked
side is a bare type parameter and the argument is a
union, the conditional runs once per member and unions
the answers back together. Not once for the whole union — once each.
That is called distribution, and it is the reason
Exclude works at all.
You turn it off by removing the bareness: wrap both sides in a tuple,
[T] extends [string], and the union is tested as a single
thing. Being able to do that deliberately in both directions is the
skill.
Two consequences worth predicting before you read them:
neveris the union with no members, so distributing over it runs zero times and producesnever. Neither branch is evaluated — the answer is not the false branch, it is nothing.booleanis a union too (true | false), so it splits into two runs.
keyof over a union flips the operation, which sounds
wrong until you say it out loud: given “an A or a B”, the only keys you
can safely read are the ones both have.
infer is pattern
matching
infer U describes a shape and gives a name to the part
you want pulled out of it. It only works inside an extends
clause, because that is the only place a match is happening.
Once you have it, most of the utility types from file 08 are one line
each: unwrap a promise, unwrap an array, take a function’s return type
or its parameters. infer U extends string adds a constraint
to the match.
The same name matched in several positions combines, and how depends on where:
- values that could come out of either place → union;
- parameters that must accept whatever arrives → intersection.
That asymmetry looks like a quirk and is a tool — it is what makes
UnionToIntersection possible at the end of the file.
Mapped types are the loop
{ [K in keyof T]: T[K] } is the base form, and the body
of the type is the loop — there is no other member. From
there:
?andreadonlycan be added, and-?/-readonlyremove a modifier that was there.-?also strips| undefined.asrenames each key, usually with a template literal type.- Mapping a key to
neverdeletes it. That is the only way to filter, and it is worth recognising on sight because it looks nothing like deletion.
One special case earns its own paragraph. Written in exactly the
shape { [K in keyof T]: … }, a mapped type is
homomorphic: modifiers are preserved automatically, and
mapping an array gives back an array rather than an object with
push as a property. That is the only reason
Partial and Readonly are usable on collections
at all.
Template literal types
String manipulation in type space, with four intrinsic helpers —
Uppercase, Lowercase, Capitalize,
Uncapitalize.
A union in a slot multiplies out against every other
slot, so two unions of two give four members and these grow explosively;
the checker gives up at 100,000. Combined with infer,
templates can take strings apart, which is how typed route
parameters and typed object paths are built.
Recursion, and where you put the call
A type may refer to itself as long as the reference sits
inside something — an object, array, or tuple. A bare
type A = A has no shape to work with and is rejected.
Where the recursive call sits decides how far you get. In tail position — the recursive call is the entire result — you get roughly 1000 levels. Anywhere else, roughly 50. Same logic, twenty times the room, and exceeding either gives error TS2589, “Type instantiation is excessively deep and possibly infinite”.
Tuple length is the standard trick for arithmetic: build a tuple of N
elements, then read ["length"].
Variance: when is a container of Dogs a container of Animals?
Variance is the question of how a relationship
between two types (Dog extends Animal) carries over to
types built from them (Dog[] and Animal[]). It
has four possible answers, and the terms are worth having:
- covariant — the container follows the relationship. Reading is covariant.
- contravariant — the container reverses it. Function
parameters are contravariant, and that direction is the safe
one: a handler that copes with any
Animalcan obviously stand in for aDoghandler. - bivariant — both directions accepted. Convenient and unsound.
- invariant — neither.
TypeScript knowingly gets one case wrong for convenience: arrays are
treated as covariant even though they are writable. Assign a
Dog[] to an Animal[], push an
Animal, and the original array now holds something that is
not a Dog, with no error anywhere. File 40 shows the two
ways to close it.
The related wrinkle is that the same declaration is checked strictly as a property and loosely as a method — method syntax is bivariant. The loose version exists because the standard library requires it. Prefer the property form in your own code.
You can also write the direction down: in,
out, and in out variance
annotations. They do not change behaviour — they document
intent, catch you if a later member contradicts them, and speed up
checking.
this types
this as a type means “whatever the receiver
turns out to be”, which is what keeps a fluent chain working after
someone subclasses you — file 04’s builder, seen from the type side.
ThisType<T> sets the this inside an
object literal’s methods, which is how configuration-object APIs get
typed.
The patterns worth knowing by name
None of these are language features. Each is the rules above, composed — and knowing the names saves reverse-engineering them at 2am.
- Branding. TypeScript matches by shape, so
a
UserIdand aPostIdare the same type. Adding a property nothing else can have makes them genuinely distinct at zero runtime cost. This is how you get nominal typing in a structural system. Prettify. Does nothing except force the checker to expand a type, so tooltips show{ a: string; b: number }instead ofA & B. Pure ergonomics.UnionToIntersection. The famous one, and it is only the distribution section and theinfersection stacked: distribute a union into function types, then infer their parameter, which intersects.Equals. Assignability is not equality —anyis assignable both ways to everything — so a real identity test wraps both sides in a deferred conditional. Paired withAssert<T extends true>, that is how you write tests for types, which is what the checks at the end of the file are.
Where this leaves you
You now have both halves of TypeScript: a description layer and a computation layer over it. What you do not have is any account of where types come from across file boundaries, or what happens at the places the type layer touches something it cannot see.
That is file 10, and it is the only file in the census that does not
simply run. Everything in files 01–09 is either plain JavaScript or type
syntax that is erased. File 10 collects the constructs that emit
code — enum, namespace, parameter
properties, decorators — plus the declaration syntax that describes code
the checker will never look at. Both halves of that are the same
subject: the seam between a checked world and an unchecked one, and
every unsoundness in the language lives on it.
While you read
- For each conditional, ask whether the left side is a bare parameter — that decides distribution.
- For each mapped type, name what it maps over and what it does to each key.
- For each
infer, say exactly what shape it is matching against. - Read
neveras “no members”, not as “error”. - For recursion, find the base case before you follow the recursive one.
The file
Read one construct at a time and evaluate it by hand for a small
input before reading the comment. Mark anything you cannot explain with
// ? in the source.
// 09 — CONDITIONAL, MAPPED, AND TEMPLATE LITERAL TYPES; VARIANCE
//
// This is where TypeScript becomes a language of its own — pure, lazy,
// functional. Conditional types are `if`, mapped types are a loop, `infer` is
// pattern matching, and template literals do string work. Once you see that, the
// syntax explains itself.
//
// A reading file. Two markers, since almost everything here is a type:
//
// // type: X what the checker resolves this to, in its own words
// // => v what the expression evaluates to, on the rare line that runs
//
// Section 10 re-checks the interesting ones with a compile-time assertion, so
// the claims in this file are enforced by the checker rather than by me.
//
// It still runs (`node 09-types-advanced.ts`) and prints nothing.
//
// Mark anything you can't explain out loud with // ?
// ─── 1. THE TYPE-LEVEL OPERATORS ─────────────────────────────────────────────
//
// Three operators to move between the value world and the type world, and one to
// look inside a type. Everything later is built from these.
const value = { a: 1, b: "s", nested: { c: true } };
type TypeOf = typeof value; // VALUE WORLD -> TYPE WORLD
// type: { a: number; b: string; nested: { c: boolean; }; }
type Keys = keyof TypeOf; // type: "a" | "b" | "nested" — the KEYS as a union of literals
type Indexed = TypeOf["nested"]["c"]; // type: boolean — INDEXED ACCESS, chainable
type ArrayElement = (string[])[number]; // type: string — [number] gets the element type
type TupleElements = [1, 2, 3][number]; // type: 1 | 2 | 3 — every position, as a union
type ValueUnion = TypeOf[keyof TypeOf]; // type: number | string | { c: boolean; }
// — index by every key at once and you get every value type at once
type KeyofRecord = keyof Record<string, number>; // type: string
type KeyofIndexSignature = keyof { [k: string]: number }; // type: string | number
// Those two describe the same object and disagree. `Record` is a MAPPED type, and
// keyof a mapped type is exactly the union it was mapped over. A hand-written
// INDEX SIGNATURE also admits numeric keys, because `obj[0]` and `obj["0"]` are
// the same property — so `number` comes back too.
type KeyofArray = keyof string[]; // type: number | "length" | "at" | "pop" | ... — every
// method name as well, because those are properties like any other
// ─── 2. CONDITIONAL TYPES ────────────────────────────────────────────────────
//
// `A extends B ? X : Y` is an if. But read `extends` as "is assignable to", not
// "is exactly" — and then watch what happens when you hand it a union.
type IsString<T> = T extends string ? true : false;
type A1 = IsString<"a">; // type: true
type A2 = IsString<1>; // type: false
// This is the behaviour to really understand, because it is invisible until it
// bites. When the checked side is a BARE type parameter and the argument is a
// union, the conditional runs once per member and the answers are unioned back
// together. Not once for the whole union — once each.
type Distributed<T> = T extends string ? "str" : "other";
type D1 = Distributed<string | number>; // type: "str" | "other"
// And this is how you turn distribution OFF: wrap either side in a tuple, and the
// union is tested as a single thing. Worth being able to do deliberately.
type NotDistributed<T> = [T] extends [string] ? "str" : "other";
type D2 = NotDistributed<string | number>; // type: "other"
// `never` is the union with NO members, so distributing over it runs zero times
// and produces nothing. Neither branch is evaluated at all (39.2).
type D3 = Distributed<never>; // type: never — NOT "other"
type D4 = NotDistributed<never>; // type: "str" — [never] does extend [string]
// One more that catches people: `boolean` is a union too, so it splits.
type D6 = boolean extends true ? "yes" : "no"; // type: "no" — boolean is not `true`
type D7<T> = T extends true ? "t" : "f";
type D8 = D7<boolean>; // type: "t" | "f" — distribution over `true` and `false`
// keyof flips the operation, which sounds wrong until you say it out loud: given
// "an A or a B", the only keys you can safely read are the ones BOTH have.
type K1 = keyof ({ a: 1; shared: 2 } | { b: 3; shared: 4 }); // type: "shared"
type K2 = keyof ({ a: 1 } & { b: 2 }); // type: "a" | "b"
// ─── 3. infer ────────────────────────────────────────────────────────────────
//
// `infer U` is pattern matching: describe a shape, and name the part you want
// pulled out of it. It only works in the `extends` clause, because that is the
// only place a match is happening.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type UnwrapArray<T> = T extends (infer U)[] ? U : never;
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type FirstIfString<T> = T extends [infer F extends string, ...unknown[]] ? F : never;
// ...that last one is `infer` with a CONSTRAINT (TS 4.7+)
type B1 = UnwrapPromise<Promise<string>>; // type: string
type B4 = MyReturnType<() => number>; // type: number
type B5 = MyParameters<(a: string, b: number) => void>; // type: [a: string, b: number]
type B6 = FirstIfString<["a", 1]>; // type: "a"
// The same name matched in several places COMBINES — and how it combines depends
// on where. Values that could come out of either place: union. Parameters that
// must accept whatever arrives: intersection. That asymmetry is a tool, not a
// quirk (39.5).
type ToUnion<T> = T extends { a: infer U; b: infer U } ? U : never;
type ToIntersection<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
? U
: never;
type B2 = ToUnion<{ a: 1; b: 2 }>; // type: 1 | 2 — covariant positions union
type B3 = ToIntersection<{ a: (x: { p: 1 }) => void; b: (x: { q: 2 }) => void }>;
// type: { p: 1; } & { q: 2; } — contravariant positions intersect
// ─── 4. MAPPED TYPES ─────────────────────────────────────────────────────────
//
// A loop over keys. The body of the type IS the loop; there is no other member.
// `+` and `-` add or remove `readonly` and `?` as you go.
interface Source { a: string; b?: number; readonly c: boolean }
type Identity<T> = { [K in keyof T]: T[K] }; // the base form
type AllOptional<T> = { [K in keyof T]?: T[K] };
type AllReadonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] }; // `-` REMOVES a modifier
type Concrete<T> = { [K in keyof T]-?: T[K] }; // and `-?` also strips `| undefined`
type Flags = { [K in "read" | "write"]: boolean }; // mapping a union of keys directly
// type: { read: boolean; write: boolean; }
// `as` renames each key. And mapping a key to `never` DELETES it, which is the
// only way to filter — worth recognising on sight, since it looks nothing like
// deletion.
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };
// ^ a key mapped to `never` disappears
type C1 = Getters<{ name: string }>; // type: { getName: () => string; }
type C2 = OnlyStrings<Source>; // type: { a: string; }
type C4 = Concrete<Source>; // type: { a: string; b: number; readonly c: boolean; }
// Write it in exactly the shape `{ [K in keyof T]: ... }` and you get a special
// case: modifiers are preserved automatically, and mapping an ARRAY gives back an
// array rather than an object with `push` as a property. That is what makes
// Partial and Readonly usable on collections at all (39.4).
type MappedArray = AllOptional<string[]>; // type: (string | undefined)[] — still an array
type MappedTuple = Mutable<readonly [1, 2]>; // type: [1, 2] — still a tuple
const remapped: C1 = { getName: () => "n" };
const throughGetter = remapped.getName(); // => "n"
// ─── 5. TEMPLATE LITERAL TYPES ───────────────────────────────────────────────
//
// String manipulation at the type level. A union in one slot multiplies out
// against every other slot, so these grow fast — the checker gives up at 100,000
// members.
type EventName<T extends string> = `on${Capitalize<T>}`;
type CssUnit = `${number}px` | `${number}%`;
type Route = `/${string}`;
type Cases = [Uppercase<"a">, Lowercase<"A">, Capitalize<"ab">, Uncapitalize<"Ab">];
// type: ["A", "a", "Ab", "ab"] — the four intrinsic string manipulators
type Corner = `${"top" | "bottom"}-${"left" | "right"}`;
// type: "top-left" | "top-right" | "bottom-left" | "bottom-right" — two unions of two
type Clicked = EventName<"click">; // type: "onClick"
// Combine templates with `infer` and you can take strings apart. This is how
// typed route parameters and typed object paths are built.
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type PathParams<S extends string> = S extends `${string}:${infer Param}/${infer Rest}`
? Param | PathParams<Rest>
: S extends `${string}:${infer Last}`
? Last
: never;
type E1 = Split<"a.b.c", ".">; // type: ["a", "b", "c"]
type E2 = PathParams<"/users/:id/posts/:postId">; // type: "id" | "postId"
const corner: Corner = "top-left";
const unit: CssUnit = "10px";
// `const bad: CssUnit = "10em"` is TS2322 — the pattern really is checked.
// ─── 6. RECURSIVE TYPES ──────────────────────────────────────────────────────
//
// Types can refer to themselves as long as the reference sits inside something —
// an object, an array, a tuple. A bare `type A = A` has no shape to work with and
// is rejected.
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
// Where the recursive call sits matters. If it is the whole result — TAIL
// POSITION — you get about 1000 levels. Otherwise about 50. Same logic, twenty
// times the room. Exceeding either is TS2589, "Type instantiation is excessively
// deep and possibly infinite".
type BuildTuple<N extends number, Acc extends unknown[] = []> = Acc["length"] extends N
? Acc
: BuildTuple<N, [...Acc, unknown]>;
type Add<A extends number, B extends number> = [...BuildTuple<A>, ...BuildTuple<B>]["length"];
type F1 = Add<2, 3>; // type: 5 — arithmetic, done entirely with tuple lengths
type F2 = DeepReadonly<{ a: { b: number } }>; // type: { readonly a: { readonly b: number; }; }
const json: Json = { a: [1, "s", null] };
const nestedJson = (json as { a: Json[] }).a[1]; // => "s"
// ─── 7. VARIANCE ─────────────────────────────────────────────────────────────
//
// The question here: if a Dog is an Animal, when is a container of Dogs a
// container of Animals? The answer depends on whether you can WRITE to it — and
// TypeScript knowingly gets one case wrong, for convenience.
class Animal { move() {} }
class Dog extends Animal { bark() {} }
// Here is the unsound one, and it is worth going slowly. Reading from an Animal[]
// that is really a Dog[] is fine. WRITING to it is not, and arrays are writable.
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // allowed — arrays are treated as COVARIANT
animals.push(new Animal()); // ...and `dogs` now holds something that is not a Dog
const dogCount = dogs.length; // => 2 — the same array, seen through two types
const lastIsADog = dogs[1] instanceof Dog; // => false — with no error anywhere.
// See 40.5 for the two ways to close this.
// Function parameters go the other way, and that direction is the safe one: a
// handler that copes with any Animal can obviously stand in for a Dog handler.
type Handler<T> = (value: T) => void;
const animalHandler: Handler<Animal> = (a) => a.move();
const dogHandler: Handler<Dog> = animalHandler; // CONTRAVARIANT, and sound
// The reverse assignment is TS2322: a Dog handler cannot take just any Animal.
// A wrinkle worth knowing: the SAME declaration is checked strictly as a property
// and loosely as a method. The loose version exists because the standard library
// needs it — Array<Dog> has to be an Array<Animal>. Prefer the property form in
// your own code (37.2).
interface MethodStyle { handle(v: Dog): void } // BIVARIANT — accepts either handler
interface PropertyStyle { handle: (v: Dog) => void } // strictly checked
// You can write the direction down. It changes no behaviour: it documents intent,
// catches you if a later member contradicts it, and speeds up checking.
interface Producer<out T> { get(): T } // COVARIANT — T only comes out
interface Consumer<in T> { set(v: T): void } // CONTRAVARIANT — T only goes in
interface Both<in out T> { get(): T; set(v: T): void } // INVARIANT — both, so neither
const producer: Producer<Animal> = { get: () => new Dog() } as Producer<Dog>;
const producedIsAnimal = producer.get() instanceof Animal; // => true — safe, because a
// Producer only ever hands values out
// ─── 8. this TYPES AND ThisType ──────────────────────────────────────────────
//
// `this` as a TYPE means "whatever the receiver actually turns out to be", which
// is what keeps a fluent chain working after someone subclasses you.
interface Chainable {
step(): this; // POLYMORPHIC this — the subclass's own type flows through
}
// ThisType<T> sets what `this` means inside an object literal's methods. It is
// how a config object gets a `this` it never declared.
type Store = { count: number } & ThisType<{ count: number; increment(): void }>;
const store: Store & { increment(): void } = {
count: 0,
increment() {
this.count++; // `this` is typed by ThisType, not by the literal it sits in
},
};
store.increment();
const counted = store.count; // => 1
// ─── 9. THE PATTERNS WORTH KNOWING BY NAME ───────────────────────────────────
//
// Five things you will meet in other people's type code. None are language
// features — each is the rules above, composed. Knowing the names saves you
// reverse-engineering them at 2am.
// BRANDING. TypeScript matches by shape, so a UserId and a PostId are the same
// type. Adding a property nothing else can have makes them genuinely distinct, at
// zero runtime cost, since the value is still just a string.
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
const userId = "u1" as UserId;
const brandIsErased = typeof userId; // => "string" — nothing was added to the value
// `const wrong: PostId = userId` is TS2322: structurally identical, nominally distinct.
// PRETTIFY does nothing whatsoever except force the checker to expand a type, so
// tooltips show `{ a: 1; b: 2 }` instead of `A & B`. Pure ergonomics.
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type G1 = Prettify<{ a: 1 } & { b: 2 }>; // type: { a: 1; b: 2; }
// UNIONTOINTERSECTION — the famous one, and it is only section 2 and section 3
// stacked: distribute a union into function parameter position, then infer that
// parameter back out, which intersects.
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends
(x: infer I) => void ? I : never;
type G2 = UnionToIntersection<{ a: 1 } | { b: 2 }>; // type: { a: 1; } & { b: 2; }
// EQUALS. Assignability is not equality — `any` is assignable both ways to
// everything. Wrapping both sides in a deferred conditional forces a real
// identity comparison instead.
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
type Assert<T extends true> = T;
// ─── 10. THE CLAIMS ABOVE, CHECKED BY THE COMPILER ───────────────────────────
//
// Every line below fails to compile if the corresponding `// type:` comment
// earlier in the file is wrong. This is the file checking itself: `npm run check`
// is the verification, not a promise in a comment.
type Check1 = Assert<Equals<A1, true>>;
type Check2 = Assert<Equals<D1, "str" | "other">>;
type Check3 = Assert<Equals<D2, "other">>;
type Check4 = Assert<Equals<D3, never>>;
type Check5 = Assert<Equals<D4, "str">>;
type Check6 = Assert<Equals<D8, "t" | "f">>;
type Check7 = Assert<Equals<K1, "shared">>;
type Check8 = Assert<Equals<K2, "a" | "b">>;
type Check9 = Assert<Equals<B2, 1 | 2>>;
type Check10 = Assert<Equals<B5, [a: string, b: number]>>;
type Check11 = Assert<Equals<B6, "a">>;
type Check12 = Assert<Equals<C2, { a: string }>>;
type Check13 = Assert<Equals<MappedTuple, [1, 2]>>;
type Check14 = Assert<Equals<E1, ["a", "b", "c"]>>;
type Check15 = Assert<Equals<E2, "id" | "postId">>;
type Check16 = Assert<Equals<Clicked, "onClick">>;
type Check17 = Assert<Equals<Add<2, 3>, 5>>;
type Check18 = Assert<Equals<Keys, "a" | "b" | "nested">>;
type Check19 = Assert<Equals<TupleElements, 1 | 2 | 3>>;
type Check20 = Assert<Equals<Cases, ["A", "a", "Ab", "ab"]>>;
type Check21 = Assert<Equals<ValueUnion, number | string | { c: boolean }>>;
type Check22 = Assert<Equals<KeyofRecord, string>>;
type Check23 = Assert<Equals<KeyofIndexSignature, string | number>>;
type Check24 = Assert<"length" extends KeyofArray ? true : false>;
type Check25 = Assert<number extends KeyofArray ? true : false>;
type Check26 = Assert<Equals<C1, { getName: () => string }>>;
type Check27 = Assert<Equals<C4, { a: string; b: number; readonly c: boolean }>>;
type Check28 = Assert<Equals<Flags, { read: boolean; write: boolean }>>;
type Check29 = Assert<
Equals<Corner, "top-left" | "top-right" | "bottom-left" | "bottom-right">
>;
type Check30 = Assert<Equals<G1, { a: 1; b: 2 }>>;
type Check31 = Assert<Equals<MappedArray, (string | undefined)[]>>;
type Check32 = Assert<Equals<F2, { readonly a: { readonly b: number } }>>;
// `type Check33 = Assert<Equals<any, unknown>>` fails, which is the point of Equals.