Emmanuel Akinfulubi

TypeScript: Advanced types

Unions and intersections, type guards, assertions, generics, mapped and conditional types.

13 exercises

1 A variable that holds text or a number

easy

Declare a variable that may hold either text or a number. Then write a function that accepts text, a number or a boolean and reports which one it got.

Stuck? Three hints, smallest first
  1. 1. A union is written with a vertical bar: string | number.
  2. 2. Inside the function you cannot use a string method until you have proved it is a string. typeof is the proof.
  3. 3. typeof value already returns the words "string", "number" and "boolean", so you may not need any branching at all.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

let result: string | number = 'forty two';
result = 42;

function whichType(value: string | number | boolean): string {
  // typeof hands back exactly the word we want, so no if-chain is needed.
  return typeof value;
}

// And a version that does branch, because usually you want different work
// per type rather than just the name.
function describe(value: string | number | boolean): string {
  if (typeof value === 'string') return `text of length ${value.length}`;
  if (typeof value === 'number') return `number, doubled is ${value * 2}`;
  return `boolean, flipped is ${!value}`;
}

console.log(whichType('hi'), whichType(7), whichType(true));
console.log(describe('hi'));
console.log(describe(7));
console.log(describe(true));

test('result now holds a number', result, 42);
test('whichType on text', whichType('hi'), 'string');
test('whichType on a number', whichType(7), 'number');
test('whichType on a boolean', whichType(true), 'boolean');
test('describe text', describe('hi'), 'text of length 2');
test('describe number', describe(7), 'number, doubled is 14');
test('describe boolean', describe(true), 'boolean, flipped is false');

Output


      

The union is the easy half. The half that matters is that inside describe you cannot touch value.length until typeof value === 'string' has run. TypeScript follows the same control flow you do, and only lets you use what it has proved.

Answers TS Advanced 1 in the original set.

2 A function returning either a boolean or a number

easy

Write a function taking a boolean and a number that may return either type. Show a caller handling both possibilities.

Stuck? Three hints, smallest first
  1. 1. The return type is a union too: : boolean | number.
  2. 2. Whatever the caller does with the result, it must handle both branches. That is the price of the union.
  3. 3. Returning a union is often a smell. Consider whether two functions would read better, and say so in a comment.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function combine(flag: boolean, n: number): boolean | number {
  // When the flag is on, the caller wants the number doubled.
  // When it is off, they want to know whether the number was positive.
  return flag ? n * 2 : n > 0;
}

function report(value: boolean | number): string {
  return typeof value === 'number' ? `number ${value}` : `boolean ${value}`;
}

const a = combine(true, 21);
const b = combine(false, -3);

console.log(report(a));
console.log(report(b));

// Worth saying out loud: a function whose return type is a union forces every
// caller to branch. Two clearly named functions are usually kinder:
function doubled(n: number): number { return n * 2; }
function isPositive(n: number): boolean { return n > 0; }

test('flag on doubles', a, 42);
test('flag off tests the sign', b, false);
test('report handles the number', report(a), 'number 42');
test('report handles the boolean', report(b), 'boolean false');
test('the split version, number half', doubled(21), 42);
test('the split version, boolean half', isPositive(-3), false);

Output


      

This compiles, and it is still worth pausing on. A union return type pushes work onto every caller forever. A union parameter type is usually a kindness; a union return type is usually two functions wearing one coat.

Answers TS Advanced 2 in the original set.

3 Two shapes, and a union of them

fair

Define an interface for a car with a make and model. Define a second shape for a bus that adds a payload capacity. Then make a type that is either one, and write a function that handles both safely.

Stuck? Three hints, smallest first
  1. 1. Reading a property that only one member of the union has is an error until you check for it.
  2. 2. 'payloadCapacity' in vehicle narrows the union to the member that has that key.
  3. 3. The alternative is a shared literal field, often called kind, which is the discriminated-union pattern.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

interface Car {
  make: string;
  model: string;
}

type Bus = {
  make: string;
  model: string;
  payloadCapacity: number;
};

type Vehicle = Car | Bus;

function describe(vehicle: Vehicle): string {
  const base = `${vehicle.make} ${vehicle.model}`;
  // vehicle.payloadCapacity is not readable yet: a Car has no such key.
  if ('payloadCapacity' in vehicle) {
    return `${base}, carries ${vehicle.payloadCapacity}kg`;
  }
  return `${base}, a car`;
}

const car: Car = { make: 'Toyota', model: 'Corolla' };
const bus: Bus = { make: 'Toyota', model: 'Coaster', payloadCapacity: 400 };

console.log(describe(car));
console.log(describe(bus));

// The tidier long-term shape: give each member a tag.
type Tagged =
  | { kind: 'car'; make: string }
  | { kind: 'bus'; make: string; payloadCapacity: number };

function describeTagged(v: Tagged): string {
  switch (v.kind) {
    case 'car': return `${v.make}: a car`;
    case 'bus': return `${v.make}: carries ${v.payloadCapacity}kg`;
  }
}

console.log(describeTagged({ kind: 'bus', make: 'Toyota', payloadCapacity: 400 }));

test('car description', describe(car), 'Toyota Corolla, a car');
test('bus description', describe(bus), 'Toyota Coaster, carries 400kg');
test('tagged union', describeTagged({ kind: 'car', make: 'Kia' }), 'Kia: a car');

Output


      

Both narrowing styles work, and the second scales better. With a kind tag, a switch covers every case and TypeScript can tell you when you have missed one. With the in operator you are inspecting shapes, which gets fragile once two members share a key.

Answers TS Advanced 3 in the original set.

4 A type guard on text or a number

easy

Write a function taking either text or a number. Use a check inside the function to work out which one arrived, and log a message suited to each.

Stuck? Three hints, smallest first
  1. 1. The check is typeof value === 'string'. That is the type guard.
  2. 2. After the check, the compiler knows the type inside that branch and lets you use its methods.
  3. 3. Add an else and note that in it the type is number, with nothing left over.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function announce(value: string | number): string {
  if (typeof value === 'string') {
    // In here, value is string. Text methods are allowed.
    return `text: "${value.trim()}" has ${value.length} characters`;
  }
  // No union left. TypeScript knows this is number without being told.
  return `number: ${value.toFixed(2)}`;
}

console.log(announce('  Lagos  '));
console.log(announce(3.14159));

// Proof the narrowing is real and not just convention: a variable typed
// as the union cannot use either method before the check.
function lengthOrDigits(value: string | number): number {
  return typeof value === 'string' ? value.length : String(value).length;
}

test('text branch', announce('  Lagos  '), 'text: "Lagos" has 9 characters');
test('number branch', announce(3.14159), 'number: 3.14');
test('length of text', lengthOrDigits('abcd'), 4);
test('digits of a number', lengthOrDigits(1234), 4);

Output


      

Note the message for the text branch: value.trim() removed the spaces but value.length is still 9, because it measures the original. Small thing, but it is the kind of detail an assertion catches and a glance does not.

Answers TS Advanced 4 in the original set.

5 Is this number odd, and is it really a number?

fair

Write a function that reports whether a number is odd. Guard the input so that a value which is not a usable number is rejected rather than quietly giving a wrong answer.

Stuck? Three hints, smallest first
  1. 1. The type says number, but NaN and Infinity are both numbers as far as the type system is concerned.
  2. 2. Number.isInteger(n) rules out NaN, Infinity and fractions in one call.
  3. 3. Remember -3 % 2 is -1, not 1, so compare with !== 0 rather than === 1.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function isOdd(n: number): boolean {
  // The annotation does not stop NaN, Infinity or 2.5 getting in here,
  // because all three are of type number.
  if (!Number.isInteger(n)) {
    throw new RangeError(`isOdd needs a whole number, got ${n}`);
  }
  // -3 % 2 is -1, so testing against 1 would call -3 even.
  return n % 2 !== 0;
}

// A guard that reports instead of throwing, for when you have untrusted input.
function isUsableInteger(value: unknown): value is number {
  return typeof value === 'number' && Number.isInteger(value);
}

console.log(isOdd(7), isOdd(8), isOdd(-3));

for (const candidate of [7, 2.5, NaN, 'nine']) {
  console.log(candidate, 'usable?', isUsableInteger(candidate));
}

const refused: string[] = [];
for (const bad of [2.5, NaN, Infinity]) {
  try { isOdd(bad); } catch (err) { refused.push((err as RangeError).message); }
}

test('7 is odd', isOdd(7), true);
test('8 is not', isOdd(8), false);
test('negative odd still odd', isOdd(-3), true);
test('guard accepts a whole number', isUsableInteger(7), true);
test('guard rejects a fraction', isUsableInteger(2.5), false);
test('guard rejects text', isUsableInteger('nine'), false);
test('three bad inputs were refused', refused.length, 3);

Output


      

The lesson is that n: number is a narrower promise than you might assume. NaN, Infinity and 2.5 all satisfy it. When a function only makes sense for whole numbers, say so at runtime with Number.isInteger; the type system cannot do it for you.

Answers TS Advanced 5 in the original set.

6 Pulling typed values out of a mixed array

fair

Declare an array holding numbers, text and booleans together. Separate them into three arrays of a single type each, and keep the compiler satisfied about what ended up where.

Stuck? Three hints, smallest first
  1. 1. filter alone leaves the element type unchanged, so the result is still the mixed union.
  2. 2. A type predicate, (v): v is number => typeof v === 'number', tells filter what survived.
  3. 3. This is the honest alternative to writing as number[] and hoping.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const mixed: (string | number | boolean)[] = [1, 'two', true, 3, 'four', false];

// A predicate says what filter proved, so the result type narrows properly.
const isNumber = (v: unknown): v is number => typeof v === 'number';
const isString = (v: unknown): v is string => typeof v === 'string';
const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean';

const numbers: number[] = mixed.filter(isNumber);
const strings: string[] = mixed.filter(isString);
const booleans: boolean[] = mixed.filter(isBoolean);

// Because the types are real, the methods are available with no casting.
const sum = numbers.reduce((n, x) => n + x, 0);
const shouted = strings.map((s) => s.toUpperCase());
const anyTrue = booleans.some((b) => b);

console.log('numbers:', numbers, 'sum', sum);
console.log('strings:', strings, '->', shouted);
console.log('booleans:', booleans, 'any true?', anyTrue);

test('numbers extracted', numbers, [1, 3]);
test('strings extracted', strings, ['two', 'four']);
test('booleans extracted', booleans, [true, false]);
test('sum of the numbers', sum, 4);
test('strings were really strings', shouted, ['TWO', 'FOUR']);
test('at least one true', anyTrue, true);

Output


      

The original exercise says "use type assertions". This solution deliberately does not, because mixed.filter(...) as number[] would be a claim rather than a check: if the filter were wrong, nothing would complain until something broke. A type predicate gets the same narrow type and is actually verified.

Answers TS Advanced 6 in the original set.

7 Casting an any to text

easy

Declare a value as any, then use an assertion to treat it as text. Then show why unknown is the better starting point.

Stuck? Three hints, smallest first
  1. 1. value as string is the assertion. There is also the older <string>value form; avoid it, since it clashes with JSX.
  2. 2. any switches checking off completely. unknown keeps it on and forces you to prove things.
  3. 3. An assertion has no runtime effect. If the value was not really text, the failure happens later and somewhere else.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

let loose: any = 'a string in disguise';
const asText = loose as string;

console.log('length via assertion:', asText.length);

// Why any is dangerous: this compiles, and it is a lie.
const lie: any = 42;
const pretend = lie as string;
// pretend.toUpperCase() would throw at runtime. Nothing warned us.
const brokeAtRuntime = typeof pretend === 'string';

// unknown is the safe version of the same idea.
const safer: unknown = 'also a string';
// safer.length would be an error: 'safer' is of type 'unknown'.
const checked = typeof safer === 'string' ? safer.length : 0;

console.log('any let a number pose as text:', brokeAtRuntime);
console.log('unknown forced a check, length:', checked);

test('assertion gave us the length', asText.length, 20);
test('the assertion on a number was a lie', brokeAtRuntime, false);
test('unknown plus a check is honest', checked, 13);

Output


      

as is a promise, not a conversion. The second block shows the cost: lie as string compiles, and typeof pretend === 'string' is false at runtime, which proves the compiler was told something untrue. Start from unknown and the compiler makes you prove it instead.

Answers TS Advanced 7 in the original set.

8 Text, or an array of text

fair

Write a function taking either a single piece of text or an array of text. For a single value return its length; for an array return the total length of all its items.

Stuck? Three hints, smallest first
  1. 1. Array.isArray(value) is the guard that separates the two cases.
  2. 2. For the array case, reduce adds up the lengths in one pass.
  3. 3. A neat trick: normalise first with const items = Array.isArray(v) ? v : [v], then you have one code path.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function totalLength(value: string | string[]): number {
  if (Array.isArray(value)) {
    return value.reduce((n, s) => n + s.length, 0);
  }
  return value.length;
}

// The same job with one code path instead of two, by normalising the input.
function totalLengthTidy(value: string | string[]): number {
  const items = Array.isArray(value) ? value : [value];
  return items.reduce((n, s) => n + s.length, 0);
}

console.log(totalLength('Lagos'));
console.log(totalLength(['Lagos', 'Abuja', 'Kano']));
console.log(totalLengthTidy('Lagos'), totalLengthTidy(['Lagos', 'Abuja', 'Kano']));

test('single value', totalLength('Lagos'), 5);
test('array of three', totalLength(['Lagos', 'Abuja', 'Kano']), 14);
test('empty array', totalLength([]), 0);
test('tidy version, single', totalLengthTidy('Lagos'), 5);
test('tidy version, array', totalLengthTidy(['Lagos', 'Abuja', 'Kano']), 14);

Output


      

The second version is the one to remember. Accepting "one thing or many things" is common, and turning it into "always many things" on the first line means the rest of the function has a single shape to deal with. Array.isArray narrows properly, so no assertion is needed.

Answers TS Advanced 8 in the original set.

9 A generic function that hands back what it was given

easy

Write a function that accepts a value of any type and returns the same value, keeping its type intact. Try it with several different types and show that the type survives.

Stuck? Three hints, smallest first
  1. 1. The type parameter goes in angle brackets: function identity<T>(value: T): T.
  2. 2. With any the type is lost. With <T> the caller’s type comes out the other end.
  3. 3. You almost never pass T explicitly. TypeScript works it out from the argument.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

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

// The type survives, so type-specific methods are available afterwards.
const text = identity('Lagos');          // string
const count = identity(42);              // number
const flag = identity(true);             // boolean
const list = identity([1, 2, 3]);        // number[]
const shape = identity({ plate: 'LAG-441-XA' });

console.log(text.toUpperCase());         // allowed: it is a string
console.log(count.toFixed(1));           // allowed: it is a number
console.log(list.map((n) => n * 2));     // allowed: it is number[]
console.log(shape.plate);

// Compare with the any version, which throws the type away.
function identityAny(value: any): any { return value; }
const lost = identityAny('Lagos');
// lost.anythingAtAll() would compile. That is the problem.

test('text survived as text', text.toUpperCase(), 'LAGOS');
test('number survived as number', count.toFixed(1), '42.0');
test('boolean survived', flag, true);
test('array survived with its element type', list.map((n) => n * 2), [2, 4, 6]);
test('object shape survived', shape.plate, 'LAG-441-XA');
test('the any version still returns the value', lost, 'Lagos');

Output


      

identity looks pointless and is the clearest possible illustration of what a type parameter is: a placeholder the caller fills in. Compare the last block. any also returns the value, but the returned type is any, so every method call on it is unchecked from then on.

Answers TS Advanced 9 in the original set.

10 A generic swap that respects array bounds

fair

Write a function taking an array of any type plus two positions, swapping the elements at those positions and returning the array. Refuse positions that are out of range.

Stuck? Three hints, smallest first
  1. 1. The signature starts function swap<T>(items: T[], i: number, j: number).
  2. 2. Under this course’s settings items[i] is T | undefined, so a bounds check is not optional.
  3. 3. Returning a new array rather than mutating the caller’s one is usually the kinder choice. Show both.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function swap<T>(items: T[], i: number, j: number): T[] {
  if (i < 0 || j < 0 || i >= items.length || j >= items.length) {
    throw new RangeError(`swap: ${i} or ${j} is outside 0..${items.length - 1}`);
  }
  // A copy, so the caller's array is left alone.
  const out = [...items];
  // The bounds check above is what makes these two reads safe.
  const a = out[i] as T;
  const b = out[j] as T;
  out[i] = b;
  out[j] = a;
  return out;
}

const plates = ['LAG-441-XA', 'ABJ-772-KM', 'ENU-318-PH'];
const swapped = swap(plates, 0, 2);

const numbers = swap([1, 2, 3, 4], 1, 3);

let refused = '';
try { swap(plates, 0, 9); } catch (err) { refused = (err as RangeError).message; }

console.log('original untouched:', plates);
console.log('swapped copy      :', swapped);
console.log('numbers           :', numbers);
console.log('refused           :', refused);

test('ends were exchanged', swapped, ['ENU-318-PH', 'ABJ-772-KM', 'LAG-441-XA']);
test('the original was not mutated', plates[0], 'LAG-441-XA');
test('works for numbers too', numbers, [1, 4, 3, 2]);
test('out of range was refused', refused.includes('outside'), true);
test('swapping a position with itself is a no-op', swap([1, 2], 0, 0), [1, 2]);

Output


      

The two as T casts are the interesting part. With noUncheckedIndexedAccess on, out[i] is T | undefined even though the bounds check just proved otherwise, because TypeScript does not track that relationship. This is one of the few places an assertion is genuinely right: the check above it is the proof.

Answers TS Advanced 10 in the original set.

11 A mapped type that makes every field optional

hard

Build a type that takes an object type and returns the same shape with every property optional. Test it against an example, and compare it with the one TypeScript already ships.

Stuck? Three hints, smallest first
  1. 1. The shape is { [K in keyof T]?: T[K] }. The ? is what adds the optionality.
  2. 2. keyof T gives a union of the key names; K in walks that union.
  3. 3. TypeScript already has this as Partial<T>. Writing it yourself once is how it stops looking like magic.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

type Booking = {
  reference: string;
  seats: number;
  tier: 'economy' | 'luxury';
};

// The mapped type: walk every key of T and re-declare it as optional.
type Optional<T> = { [K in keyof T]?: T[K] };

// Every field may now be left out.
const patch: Optional<Booking> = { seats: 3 };
const empty: Optional<Booking> = {};
const full: Optional<Booking> = { reference: 'BK-1', seats: 2, tier: 'luxury' };

// The built-in does exactly the same thing.
const builtIn: Partial<Booking> = { tier: 'economy' };

function applyPatch(base: Booking, changes: Optional<Booking>): Booking {
  return { ...base, ...changes };
}

const original: Booking = { reference: 'BK-0001', seats: 1, tier: 'economy' };
const updated = applyPatch(original, { seats: 4, tier: 'luxury' });

console.log('patch     :', patch);
console.log('updated   :', updated);
console.log('original  :', original);

test('a partial object is accepted', patch.seats, 3);
test('so is an empty one', Object.keys(empty).length, 0);
test('and a complete one', full.reference, 'BK-1');
test('the built-in behaves the same', builtIn.tier, 'economy');
test('patch applied', updated, { reference: 'BK-0001', seats: 4, tier: 'luxury' });
test('the original was not changed', original.seats, 1);

Output


      

Optional<T> is Partial<T>, and this is the pattern behind most of the utility types. Once you can read { [K in keyof T]?: T[K] }, you can read Required, Readonly and Pick too, because they are the same walk with a different modifier.

Answers TS Advanced 11 in the original set.

12 A mapped type that freezes every field

hard

Build a type that takes an object type and returns the same shape with every property read-only. Show that assignment is refused, and note what it does not protect.

Stuck? Three hints, smallest first
  1. 1. The modifier is readonly in front of the key: { readonly [K in keyof T]: T[K] }.
  2. 2. This is checked at compile time only. Nothing is frozen at runtime unless you call Object.freeze.
  3. 3. It is also shallow: a nested object’s own fields stay writable.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

type Seat = {
  row: string;
  number: number;
  meta: { checkedIn: boolean };
};

type Frozen<T> = { readonly [K in keyof T]: T[K] };

const seat: Frozen<Seat> = { row: 'D', number: 12, meta: { checkedIn: false } };

// Each of these is a compile error:
// seat.row = 'E';        // Cannot assign to 'row' because it is a read-only property.
// seat.number = 13;      // same

// But it is SHALLOW. The nested object is still open:
seat.meta.checkedIn = true;

// And it is compile-time only. Runtime freezing is a separate call:
const reallyFrozen = Object.freeze({ row: 'D', number: 12 });
let threw = false;
try {
  // In strict mode, which every module is, this throws.
  (reallyFrozen as { row: string }).row = 'E';
} catch { threw = true; }

console.log('nested field was changed:', seat.meta.checkedIn);
console.log('runtime write threw     :', threw);
console.log('built-in equivalent     :', Object.keys({} as Readonly<Seat>).length);

test('the readonly fields still read fine', seat.row, 'D');
test('readonly is shallow, nested changed', seat.meta.checkedIn, true);
test('Object.freeze does refuse at runtime', threw, true);
test('and the frozen value is unchanged', reallyFrozen.row, 'D');

Output


      

Two limits worth carrying away. readonly is compile-time only: it disappears from the output, so nothing stops a plain JavaScript caller writing to the field. And it is shallow, which is why seat.meta.checkedIn = true sails through. For a real guarantee you need Object.freeze as well, and even that is shallow. The built-in name for this type is Readonly<T>.

Answers TS Advanced 12 in the original set.

13 A conditional type that filters an array by type

hard

Build a type that, given a union and a target type, keeps only the members assignable to the target. Then write the matching runtime function so you can pull just the numbers or just the text out of a mixed array.

Stuck? Three hints, smallest first
  1. 1. The conditional form is T extends U ? T : never. A never member vanishes from a union.
  2. 2. Applied to a union, a conditional type distributes over each member automatically.
  3. 3. The type only describes the result. You still need a runtime predicate to do the actual filtering.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

type Mixed = string | number | boolean;

// Keep only the union members assignable to U. Anything else becomes never,
// and never disappears when unions are combined.
type KeepOnly<T, U> = T extends U ? T : never;

type JustNumbers = KeepOnly<Mixed, number>;   // number
type JustText = KeepOnly<Mixed, string>;      // string

// The runtime half. The type above cannot filter anything by itself.
function pickNumbers(items: Mixed[]): JustNumbers[] {
  return items.filter((v): v is number => typeof v === 'number');
}

function pickText(items: Mixed[]): JustText[] {
  return items.filter((v): v is string => typeof v === 'string');
}

const values: Mixed[] = [1, 'two', true, 3, 'four', false, 5];

const numbers = pickNumbers(values);
const text = pickText(values);

// Because the return types are narrow, these methods are allowed.
const sum = numbers.reduce((n, x) => n + x, 0);
const joined = text.join('-');

console.log('numbers:', numbers, 'sum', sum);
console.log('text   :', text, 'joined', joined);

// Exclude<T, U> is the built-in mirror image: drop what matches instead.
type WithoutBoolean = Exclude<Mixed, boolean>;   // string | number
const noBool: WithoutBoolean = 7;

test('numbers only', numbers, [1, 3, 5]);
test('text only', text, ['two', 'four']);
test('sum proves they are numbers', sum, 9);
test('join proves they are text', joined, 'two-four');
test('Exclude dropped the boolean', typeof noBool, 'number');

Output


      

The pairing is the lesson. KeepOnly describes the result type but does no work; filter with a predicate does the work but would not narrow the type on its own. You need both, and Extract and Exclude in the standard library are this same conditional written once for you.

Answers TS Advanced 13 in the original set.

The exercise list follows w3resource's TypeScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.