EduFlow

Section 1 · Types as a Design Tool

Types That Earn Their Keep

12 min3 min readCode playground

Lesson at a glance

Tell a type that prevents a bug from a type that only decorates code, and read the type tests every exercise in this course uses.

  1. 1Two ways to type the same thing
  2. 2The running example
  3. 3How the exercises test types

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Here is a bug that shipped in a checkout I worked on. A refund function took an amount and the payment provider expected cents. One caller passed dollars. Customers got refunded 1% of what they were owed, and nobody noticed for two weeks, because every line of that code was “typed”: amount: number.

The types were there. They just did not earn their keep. number described the code and prevented nothing. This course is about the other kind of type, the kind that makes the bug impossible to write.

Two ways to type the same thing

Cartwheel, the store you will work with throughout this course, has five order statuses: processing, shipped, delivered, cancelled and returned. Here is the decorative version:

TypeScript
type Order = { id: number; status: string };

function canCancel(order: Order): boolean {
  return order.status === 'procesing'; // typo: always false
}

That compiles. status is string, so comparing it with any string is legal. Now the version that earns its keep:

TypeScript
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus };

function canCancel(order: Order): boolean {
  return order.status === 'procesing';
  // Error: This comparison appears to be unintentional because the types
  // 'OrderStatus' and '"procesing"' have no overlap.
}

Same runtime code, one changed line of types, and a whole class of bug is gone. You will see this move again and again: replace a wide type (string, number, object) with a narrow one that says what is actually allowed.

The running example

Lesson by lesson you will build the type layer of Cartwheel’s checkout API client: orders and their statuses, money that refuses to mix currencies, coupons, a fetch client whose responses are typed from a route map, events the checkout emits, and parsers that turn untrusted JSON into trusted types. The runtime code stays small on purpose. The interesting part is what the compiler refuses to accept.

The course runs in five steps. First you use types as a design tool: structural typing, narrowing, discriminated unions and satisfies. Then generics, with a focus on inference. Then type-level programming, with honest advice on when to stop. Then patterns that real codebases need: branded types, Result types, parsing at the boundary, typed events. Finally, shipping: compiler flags, declaration files, decorators and migrating JavaScript.

How the exercises test types

Most exercises are type-level. The playground runs TypeScript 5.9 in strict mode and gives you two helpers:

TypeScript
type T1 = Expect<Equal<OrderStatus, 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned'>>;

Equal<A, B> is true only when the two types are identical, and Expect only accepts true. So that line is a unit test: it compiles when you got the type right and fails when you did not.

The second tool is built into TypeScript. A // @ts-expect-error comment says “the next line must be an error”:

TypeScript
// @ts-expect-error: 'canceled' is not a status
canCancel({ id: 1001, status: 'canceled' });

If the line below it compiles, TypeScript reports an unused directive. That turns “this bad call is rejected” into a test, which matters as much as “this good call is accepted”. A type that accepts everything passes every positive test.

In the exercise below, the statuses are typed as string. Narrow the type and watch the compiler find the missing label for you. The next lesson looks at what “assignable” actually means in TypeScript, because it is not what most people coming from Java or C# expect.

Key takeaways

  • A type earns its keep when it makes a real bug impossible to write, not when it restates what the code already says.
  • A string where only five values are valid is a missed chance; a union of literals lets the compiler check every use.
  • Expect<Equal<A, B>> fails to compile unless the two types are identical, so it works as a unit test for types.
  • A // @ts-expect-error line asserts that the next line is rejected, and it becomes an error itself if that line compiles.

Further reading

Check your understanding

3 questions

Question 1 of 3

Which of these types catches the most real bugs for an order’s status field that can only be processing, shipped, delivered, cancelled or returned?

Question 2 of 3

What does this type test do when Total is number | undefined?

TypeScript
type T1 = Expect<Equal<Total, number>>;
Question 3 of 3

You add // @ts-expect-error above pay(order, 'EUR'), but pay accepts any string as the currency. What happens?

Practice

Code playground

Narrow the order status

Cartwheel orders have exactly five statuses: processing, shipped, delivered, cancelled and returned. Replace string in OrderStatus with a union of those five literals so the type tests pass. Once the type is narrow, the compiler points at a gap in statusLabels: fill it so statusLabel('returned') returns 'Returned'.

// Cartwheel's five order statuses come from the orders table.
// Right now the type accepts any string at all.
type OrderStatus = string;

const statusLabels: Record<OrderStatus, string> = {
  processing: 'Processing',
  shipped: 'Shipped',
  delivered: 'Delivered',
  cancelled: 'Cancelled',
};

function statusLabel(status: OrderStatus): string {
  return statusLabels[status];
}

function canCancel(status: OrderStatus): boolean {
  return status === 'processing';
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<OrderStatus, 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned'>>;

// @ts-expect-error: 'canceled' (one L) is a typo, not a status
canCancel('canceled');

console.log(statusLabel('shipped'), canCancel('processing'));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors (including the type tests)
  • statusLabel('returned') returns 'Returned'
  • canCancel still allows only processing orders to be cancelled