Build the safeguards
Eight explained steps, tradeoffs, pitfalls and a checkpoint before moving on.
Read the tutorialEight steps / Explained code / Checkpoints
TypeScript checks the program before it runs. A runtime validator checks the data that actually arrives. This lab uses both, then protects the workflow and asynchronous results separately.
Prerequisites: Try the playground first. The first pass needs only a modern browser. Editing the source needs the toolchain listed in the downloadable setup guide.
Choose a pace: 15 minutes to compare the failures; about 60–90 minutes for a first guided pass; extra time for the independent challenge.
Step 01
Start with the text-price sample and turn safeguards off. The application performs arithmetic on a value that the programmer asserted was a Quote. The compiler has already finished its work; that assertion adds no runtime check. Turn safeguards on and compare where execution stops. Keep the input unchanged so the comparison isolates the implementation.
const quote = input as Quote;
return quote.totalCents / 100;Tradeoff and pitfall. An assertion can describe an already established invariant, but it cannot establish the invariant. Changing the input until the example works hides the defect.
Check before moving on. The broken display says $NaN. The guarded path reports that price must be whole-number cents. Neither path creates a real quote.
Step 02
Read parseQuote in the code viewer. It first checks object shape, then identifier formats, then safe whole-number cents. It returns a discriminated success-or-error result. It also constructs a new object, so unrelated incoming properties do not silently become domain state. Validation is a business boundary: zero is allowed here, a negative price is not.
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };Tradeoff and pitfall. A schema library is useful for larger contracts and shared validation. This small hand-written validator keeps every check visible. Avoid any at the entry point: it disables the questions the compiler could ask.
Check before moving on. Run all three samples. Only the valid quote reaches ready. The native tests also reject null, arrays, fractional cents, infinity and unsafe integers.
Step 03
Both IDs are strings at runtime, but their meanings differ. Brand<string, Name> separates CustomerId and QuoteId in checked code. The brand is introduced only after the validator checks the prefix. The negative type tests deliberately try assigning one ID type to the other. They use @ts-expect-error so a later change that removes the distinction fails type checking.
type Brand<T, Name extends string> =
T & { readonly __brand: Name };
type CustomerId = Brand<string, "CustomerId">;Tradeoff and pitfall. Brands are compile-time distinctions and do not authenticate an identity. A string prefix is format validation, not permission to access a customer record. A deliberate assertion can still bypass a brand.
Check before moving on. Run type checking. Then change the mixed-ID sample to valid prefixes and explain which checks are compile-time and which occur at runtime.
Step 04
Read State and transition. An approved state contains a verified quote and an approver. Idle does not carry those fields. The approve transition still checks the current state and a nonempty approver because callers can request an inappropriate action at runtime. A tagged union makes the data contract precise; the transition function enforces workflow rules.
type State =
| { status: "idle" }
| { status: "ready"; quote: Quote }
| { status: "approved"; quote: Quote; approvedBy: string };Tradeoff and pitfall. One large object full of optional properties admits combinations the interface cannot explain. A state-machine library can help a larger workflow, but it introduces its own conventions. Start by naming the states and legal transitions.
Check before moving on. Try approving before loading a quote. Then load the valid sample and approve it. The first attempt becomes a clear error; the second contains a quote and reviewer.
Step 05
The event switch ends with assertNever. When an event variant is added without a corresponding case, the remaining value cannot be passed to never. Handlers uses a mapped type over event names and Extract to select each matching payload. This is a practical use of conditional types: a load handler receives a request number rather than an arbitrary event.
type Handlers<E extends { type: string }> = {
[K in E["type"]]: (event: Extract<E, { type: K }>) => string;
};Tradeoff and pitfall. A generic dispatcher may still need a carefully checked implementation because indexed union correlations can be lost. Do not erase the correlation with a broad assertion just to silence an error. For five events, an explicit switch is easy to review.
Check before moving on. Add an archive event locally. Type checking should require a handler and a transition case. Remove the experiment after observing the errors.
Step 06
Try competing responses. The older request deliberately takes longer. The broken path assigns whichever response finishes last. The guarded path gives each request a generation and accepts only the latest one. Completion order is not user intent. The trace tells you when a late result was ignored rather than pretending it never happened.
const mine = ++latest;
await waitForResponse();
if (guarded && mine !== latest) return;Tradeoff and pitfall. Debouncing reduces request frequency but does not by itself resolve response ordering. Aborting a request is helpful, yet generation checks remain useful around work that cannot be canceled.
Check before moving on. Without safeguards, Older request wins. With safeguards, Newer request remains selected. Reset during the experiment and verify that no late result repaints the cleared panel.
Step 07
The run owns an AbortController. Reset invalidates that run and aborts its timer. The component cleanup also aborts work when React unmounts. Abort listeners are removed after completion or cancellation. The active-controller check prevents the finally block of an older run from clearing the busy state of a newer run.
useEffect(() => () => active.current?.abort(), []);
if (active.current !== controller) return;Tradeoff and pitfall. Cancellation of a browser operation does not undo a server transaction. A future persistent API must reconcile uncertain results using its own operation identity. This sample has no persistent remote operation.
Check before moving on. Run the cancellation test and repeat Reset while requests are waiting. Controls should recover and the old trace should stay cleared.
Step 08
Download the matching source and run type checking plus the five behavioral tests. Extend the validator with a currency field and define a rejected state with a reason. Write the expected failure first, implement the smallest change, then explain why the runtime check and the state change belong in different functions.
node --test tests/typescript.test.mjs
# From the development source package:
pnpm run typecheckTradeoff and pitfall. Prefer a visible, explicit boundary over a clever type that nobody can maintain. The exercise proves a small synthetic contract; it does not establish production authorization, historical framework tenure or large-scale performance.
Check before moving on. Explain the malformed-data, invalid-transition and stale-response failures without reading the lesson. Then change one sample and predict the result before running it.
Independent challenge
Add currency validation, a rejected state and a retry action. Keep the previous response from committing after a reset. Explain which invariants are enforced at compile time and which require runtime decisions.
Write the expected behavior before changing the implementation. Keep the original failure as a regression case. Explain the tradeoff you accepted and the result that would make you reconsider it.
References support the language and runtime concepts. The lesson, scenarios and explanations are original Zyel Lab material.
Keep exploring
Eight explained steps, tradeoffs, pitfalls and a checkpoint before moving on.
Read the tutorialChoose a pattern and an environment for the problem you actually have.
Compare approachesA beginner route, a local setup and an extension challenge.
Open the how-to guide