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.
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:
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:
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:
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”:
// @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
stringwhere 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-errorline asserts that the next line is rejected, and it becomes an error itself if that line compiles.