Checkpoint: Type-Level Patterns

Reviewed & published by Brayan K

This checkpoint consolidates the type-level toolkit — assertions, any/unknown/never, structural typing, type vs interface, readonly, Records, template-literal and recursive types, satisfies, and branded types — into one build challenge and a short quiz.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Practise

1. Recap: The Type-Level Toolkit

Here's the whole chapter at a glance — skim it, and re-open any lesson whose one-liner doesn't click yet.

// QUICK RECAP — one runnable snippet touching several chapter ideas.

// Structural typing: any object with the right shape fits.
function label(thing) { return "[" + thing.name + "]"; }
console.log(label({ name: "Ada", age: 36 })); // [Ada]

// unknown -> narrow before use:
function lengthOf(value) {
  if (typeof value === "string") return value.length;
  if (Array.isArray(value)) return value.length;
  return 0;
}
console.log(lengthOf("hello"));     // 5
console.log(lengthOf([1, 2, 3]));   // 3
console.log(lengthOf(42));          // 0

// readonly idea, enforced at runtime with freeze:
const settings = Object.freeze({ theme: "dark" });
settings.theme = "light"; // ignored
console.log(settings.theme);        // dark

2. 🎯 Build Challenge: A Tiny Typed Event System

Put it together. Build a small event system that uses a branded validated id, a Record handler registry, an exhaustive dispatch with a never-style default guard, and an immutable frozen config. Complete the TODOs in the starter below and run it; then compare with the full solution.

// 🎯 BUILD CHALLENGE — model a tiny typed EVENT SYSTEM that exercises the
// whole chapter. Fill in the TODOs. A full solution is under "Show solution".
//
// You will combine:
//   • a BRANDED, validated EventId (smart constructor)        [Branded Types]
//   • a Record<string, handler> registry                      [Records]
//   • an EXHAUSTIVE dispatch with a default guard             [any/unknown/never]
//   • an IMMUTABLE config via Object.freeze                   [readonly/as const]

// 1) Smart constructor: throw "bad event id: <s>" unless s starts with "evt_".
function makeEventId(s) {
  // TODO: validate the prefix, then return s
  return s;
}

// 2) The handler registry (a dictionary of functions).
const handlers = {
  created: (name) => "created " + name,
  deleted: (name) => "deleted " + name,
};

// 3) dispatch(kind, name): switch on kind; default throws "unhandled kind: <kind>".
function dispatch(kind, name) {
  // TODO: handle "created" and "deleted"; throw on anything else
  return "TODO";
}

// 4) An immutable config.
const config = Object.freeze({ retries: 3, verbose: true });

// --- smoke test (works once the TODOs are done) ---
console.log(makeEventId("evt_1"));        // evt_1
console.log(dispatch("created", "user")); // created user
console.log("retries=" + config.retries); // retries=3

A complete, runnable version. Each numbered step maps to a lesson from the recap.

// FULL SOLUTION — a tiny typed event system tying the chapter together.

// 1) Branded EventId (validated) — smart constructor.
//    In TS:  type EventId = string & { readonly __brand: "EventId" };
function makeEventId(s) {
  if (!s.startsWith("evt_")) throw new Error("bad event id: " + s);
  return s; // 'as EventId' in TS — branded only AFTER validation
}

// 2) A Record<string, handler> registry (index-signature dictionary).
//    In TS:  const handlers = { ... } satisfies Record<string, (n: string) => string>;
const handlers = {
  created: (name) => "created " + name,
  deleted: (name) => "deleted " + name,
};

// 3) Exhaustive dispatch with a 'never'-style default guard.
//    In TS the default would do: const _exhaustive: never = kind;
function dispatch(kind, name) {
  switch (kind) {
    case "created": return handlers.created(name);
    case "deleted": return handlers.deleted(name);
    default: throw new Error("unhandled kind: " + kind);
  }
}

// 4) An immutable (as const / Object.freeze) config the system reads.
const config = Object.freeze({ retries: 3, verbose: true });

// --- run it ---
const id = makeEventId("evt_1");
console.log(id);                          // evt_1
console.log(dispatch("created", "user")); // created user
console.log(dispatch("deleted", "user")); // deleted user
console.log("retries=" + config.retries); // retries=3

try { makeEventId("x_1"); }
catch (e) { console.log(e.message); }     // bad event id: x_1

try { dispatch("updated", "user"); }
catch (e) { console.log(e.message); }     // unhandled kind: updated

Expected output: evt_1, created user, deleted user, retries=3, bad event id: x_1, unhandled kind: updated.

3. 📝 Checkpoint Quiz

Answer each in your head, then expand to check. If one trips you up, revisit its lesson from the recap.

No. A type assertion only changes what the compiler believes — it's erased at runtime. The value is still the string "42", so "42" as number followed by + 1 would concatenate to "421". For a real conversion, use Number("42").

Use unknown for any value whose type you don't control — parsed JSON, network data, callback inputs. Unlike any (which disables checking and spreads), unknown forces you to narrow with typeof, instanceof, or a type guard before you can use the value, keeping you safe.

Because TypeScript is structurally typed: compatibility depends on shape, not name. Both types have a name: string member, so each is assignable to a parameter typed { name: string } — the extra properties are simply ignored. Their unrelated names don't matter.

as T is an assertion: it forces the type with no validation, so mistakes pass silently. satisfies T actually checks the value against T (reporting mismatches) while preserving the precise inferred type — no widening. Prefer satisfies when shaping a literal you want validated.

Both are strings, so structurally they'd be interchangeable. A brand intersects each with a distinct phantom property (& { __brand: "UserId" } vs "OrderId"), giving them different types. The compiler then refuses to pass one where the other is required. The brand is type-only and erased at runtime.

Erased (compile-time only): as, !, any/unknown/never, readonly, as const, template-literal types, recursive types, satisfies, and brands. Real runtime behaviour comes from the JavaScript you pair them with: Object.freeze (immutability), typeof/Array.isArray (narrowing), and validation inside smart constructors.

📚 Keep learning

🎉 Checkpoint Complete

Practice quiz

Does the assertion '"42" as number' turn the value into a number at runtime?

  • Yes, it parses the string into 42
  • Yes, but only with strict mode on
  • No — assertions are erased; the value is still the string '42'
  • No, it throws a runtime error

Answer: No — assertions are erased; the value is still the string '42'. A type assertion only changes what the compiler believes; it is erased at runtime. Use Number('42') for a real conversion.

When should you prefer 'unknown' over 'any'?

  • For values whose type you don't control — unknown forces you to narrow before use
  • Never; they behave identically
  • Only for numbers
  • When you want to disable type checking

Answer: For values whose type you don't control — unknown forces you to narrow before use. Unlike 'any', which disables checking, 'unknown' forces narrowing (typeof, instanceof, a guard) before the value can be used — keeping you safe.

What is the 'never' type used for?

  • Any value at all
  • Null and undefined together
  • A function with no parameters
  • A value that can never occur — used for exhaustiveness checks

Answer: A value that can never occur — used for exhaustiveness checks. 'never' represents the impossible. In a switch default, assigning the discriminant to 'never' flags any unhandled case at compile time.

Why can both a Person and a Product satisfy a parameter typed '{ name: string }'?

  • Because they share a base class
  • Because TypeScript is structurally typed — shape, not name, decides compatibility
  • Because all objects have a name
  • Because of declaration merging

Answer: Because TypeScript is structurally typed — shape, not name, decides compatibility. TypeScript uses structural typing: any object whose shape includes 'name: string' is assignable; extra properties are ignored and names don't matter.

Which statement about 'type' aliases vs 'interface' is correct?

  • Interfaces merge across declarations and extend; type aliases name unions, tuples, and mapped types
  • Interfaces can name unions; type aliases cannot
  • They are completely interchangeable in all cases
  • Type aliases can be re-declared to merge members

Answer: Interfaces merge across declarations and extend; type aliases name unions, tuples, and mapped types. Interfaces support declaration merging and 'extends'; type aliases are closed but can express unions, tuples, and mapped types that interfaces can't.

What is the difference between 'value as T' and 'value satisfies T'?

  • They are identical
  • 'satisfies T' converts the value; 'as T' validates it
  • 'as T' forces the type with no validation; 'satisfies T' checks the value against T while keeping the precise inferred type
  • 'satisfies T' is erased but 'as T' runs at runtime

Answer: 'as T' forces the type with no validation; 'satisfies T' checks the value against T while keeping the precise inferred type. 'as' is an unchecked assertion. 'satisfies' validates the value against T and reports mismatches while preserving the narrow inferred type (no widening).

How do branded types stop you mixing a UserId and an OrderId, given both are strings?

  • They store a hidden runtime tag on each value
  • Each is intersected with a distinct phantom property, giving them different (nominal) types
  • They convert the strings to numbers
  • They use Object.freeze to lock the value

Answer: Each is intersected with a distinct phantom property, giving them different (nominal) types. Brands intersect each string with a distinct phantom property (e.g. & { __brand: 'UserId' }), so the compiler refuses to swap them. The brand is type-only and erased.

What does a template literal type let you express?

  • A runtime string built with backticks
  • A multi-line comment
  • A recursive function
  • String types built from patterns, such as 'on${Capitalize<string>}'

Answer: String types built from patterns, such as 'on${Capitalize<string>}'. Template literal types compose string types from patterns and unions distribute over them; helpers like Capitalize transform the pieces. They are compile-time only.

What characterizes a recursive type?

  • A type that calls a function
  • A self-referential type, like a JSON value or tree, that refers to itself in its own definition
  • A type that can only be used once
  • A type alias for 'any'

Answer: A self-referential type, like a JSON value or tree, that refers to itself in its own definition. Recursive types refer to themselves to model nested structures (JSON, trees, linked lists), e.g. DeepReadonly<T> applying itself to nested properties.

Which of these has REAL runtime behaviour rather than being erased?

  • The 'readonly' modifier
  • A branded type
  • Object.freeze
  • The 'satisfies' operator

Answer: Object.freeze. Object.freeze actually enforces immutability at runtime. 'readonly', 'satisfies', and brands are compile-time only and leave no trace in emitted JavaScript.

Continue this course

Frequently asked questions

What should I be able to do after this checkpoint?

You should be able to reach for the right type-level tool by name: assert a type with 'as' (and know it doesn't convert), pick between any, unknown, and never, lean on structural typing, choose type vs interface, lock data with readonly and as const, type dictionaries with index signatures and Record, build string types with template literals, write recursive types, validate-without-widening using satisfies, and enforce nominal distinctions with branded types. The build challenge proves you can combine several of these into one small system.

I'm fuzzy on one topic — where do I go back?

Each topic has its own lesson: Type Assertions, any vs unknown vs never, void/null/undefined, Structural Typing, type vs interface, readonly & as const, Records & Index Signatures, Template Literal Types, Recursive Types, satisfies, and Branded Types. Re-run that lesson's worked examples and 'Your Turn' until the behaviour is obvious, then come back and redo the build challenge from scratch without peeking at the solution.

How do these compile-time features behave at runtime?

Almost all of them are erased: type assertions, any/unknown/never, readonly, as const, template literal types, recursive types, satisfies, and branded types leave no trace in the emitted JavaScript. The only ones with real runtime behaviour are the JavaScript constructs you pair them with — Object.freeze for true immutability, typeof/Array.isArray for narrowing, and validation logic inside smart constructors. The types guide you while coding; the runtime behaviour comes from ordinary JS.

What comes after this checkpoint?

Next is Declaration Merging, which builds on the type-vs-interface lesson by showing how interfaces (and namespaces) combine across declarations to augment existing types — including library and global types you don't own. After the type-level toolkit you've assembled here, you'll be ready to extend and reshape types provided by others.

Related lessons