Worked review 2: form validation and discriminated-union state
This review’s domain is a form’s state, modeled as a discriminated union — exactly the shape this course’s narrowing lessons teach as the correct pattern. The bugs here are not in choosing a discriminated union; they are in how confidently four small, plausible-looking mistakes hide inside it.
The program
Section titled “The program”Review this before reading further
// @runinterface FormValues { email: string; discountCode?: string; couponValue?: number }
type FormState = | { status: "idle" } | { status: "editing"; values: FormValues } | { status: "submitting"; values: FormValues } | { status: "success"; values: FormValues } | { status: "error"; values: FormValues; message?: string };
function isValidForm(values: unknown): values is FormValues { return true; // BUG 1: type predicate that never actually checks anything}
function couponSavings(values: FormValues): number { return values.couponValue || 5; // BUG 2: || -- default promo of 5, but an explicit 0 (opt out) looks like "not set" too}
function describeState(state: FormState): string { switch (state.status) { case "idle": return "Fill in the form to continue."; case "editing": return `Editing, coupon savings: ${couponSavings(state.values)}`; case "submitting": return "Submitting..."; case "success": return "Thanks! Your form was submitted."; // BUG 3: "error" case forgotten -- no default, no never check, no error } return "Unknown state";}
function renderErrorBanner(state: FormState): string { if (state.status !== "error") return ""; return `Error: ${state.message!.toUpperCase()}`; // BUG 4: ! on an optional field}
function submitForm(raw: unknown): FormState { if (!isValidForm(raw)) return { status: "idle" }; return { status: "submitting", values: raw };}
const editingWithZeroCoupon: FormState = { status: "editing",};console.log(describeState(editingWithZeroCoupon));
const submitted = submitForm({ totallyNotAFormValues: true });console.log(describeState(submitted));
console.log(describeState(errorState));console.log(renderErrorBanner(errorState)); Click Check types: tsc --strict reports zero errors. Click Run:
Editing, coupon savings: 5Submitting...Unknown stateTypeError: Cannot read properties of undefined (reading 'toUpperCase') at renderErrorBanner (...)Four wrong lines in a row, and not one of them looks like a crash until the last one actually is.
Find the bugs before reading on
Section titled “Find the bugs before reading on”Two of these are named directly in this course’s spec for this review; two more are hiding in plain sight. Try to name all four before opening the answers.
Bug 1 — line 10, isValidForm returns true unconditionally
Symptom: the function’s signature (values is FormValues) promises it checked the shape; the body checks nothing at all.
Evidence: submitForm({ totallyNotAFormValues: true }) — an object with none of FormValues’s fields — passes isValidForm anyway, and submitForm happily returns { status: "submitting", values: raw } with a completely wrong values. describeState then prints "Submitting..." for garbage input instead of rejecting it.
Fix: check the fields the predicate claims to check:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowfunction isValidForm(values: unknown): values is FormValues { return typeof values === "object" && values !== null && typeof (values as Record<string, unknown>).email === "string";}Bug 2 — line 15, values.couponValue || 5
Symptom: a user who explicitly enters 0 (opting out of a promo) is treated identically to a user who never touched the coupon field at all.
Evidence: editingWithZeroCoupon has couponValue: 0 — an explicit opt-out — and describeState reports "Editing, coupon savings: 5", not 0.
Fix: ?? distinguishes “not set” from “set to a falsy value”:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowfunction couponSavings(values: FormValues): number { return values.couponValue ?? 5;}Bug 3 — line 28, the "error" case is missing from the switch
Symptom: describeState handles four of FormState’s five variants; the fifth silently falls through to a hardcoded fallback string instead of a compile error.
Evidence: describeState(errorState) prints "Unknown state" — a real, valid FormState produced a message meant only for something the type system had never seen before.
Fix: a default branch with a never-typed exhaustiveness check turns a future missed case into a compile error instead of a wrong string:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowfunction describeState(state: FormState): string { switch (state.status) { // ... case "error": return `Error state: ${state.message ?? "unknown error"}`; default: { const exhaustiveCheck: never = state; return exhaustiveCheck; } }}Bug 4 — line 34, state.message!.toUpperCase()
Symptom: message on the "error" variant is typed string | undefined — an error state does not always carry a message — and ! tells the checker to ignore that.
Evidence: the real crash. errorState was constructed with no message at all; state.message! is undefined, and .toUpperCase() throws.
Fix: fall back to a default message instead of asserting one exists:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowfunction renderErrorBanner(state: FormState): string { if (state.status !== "error") return ""; return `Error: ${(state.message ?? "unknown error").toUpperCase()}`;}The fixed version
Section titled “The fixed version”// @runinterface FormValues { email: string; discountCode?: string; couponValue?: number }
type FormState = | { status: "idle" } | { status: "editing"; values: FormValues } | { status: "submitting"; values: FormValues } | { status: "success"; values: FormValues } | { status: "error"; values: FormValues; message?: string };
function isValidForm(values: unknown): values is FormValues { return typeof values === "object" && values !== null && typeof (values as Record<string, unknown>).email === "string";}
function couponSavings(values: FormValues): number { return values.couponValue ?? 5;}
function describeState(state: FormState): string { switch (state.status) { case "idle": return "Fill in the form to continue."; case "editing": return `Editing, coupon savings: ${couponSavings(state.values)}`; case "submitting": return "Submitting..."; case "success": return "Thanks! Your form was submitted."; case "error": return `Error state: ${state.message ?? "unknown error"}`; default: { const exhaustiveCheck: never = state; return exhaustiveCheck; } }}
function renderErrorBanner(state: FormState): string { if (state.status !== "error") return ""; return `Error: ${(state.message ?? "unknown error").toUpperCase()}`;}
function submitForm(raw: unknown): FormState { if (!isValidForm(raw)) return { status: "idle" }; return { status: "submitting", values: raw };}
const editingWithZeroCoupon: FormState = { status: "editing",};console.log(describeState(editingWithZeroCoupon));
const submitted = submitForm({ totallyNotAFormValues: true });console.log(describeState(submitted));
console.log(describeState(errorState));console.log(renderErrorBanner(errorState));Real output:
Editing, coupon savings: 0Fill in the form to continue.Error state: unknown errorError: UNKNOWN ERROREvery line is now correct: the explicit 0 coupon survives, the garbage input is correctly rejected back to "idle", and the error state renders a real fallback message instead of crashing.
What the tools caught
Section titled “What the tools caught”tsc --strict: zero errors, on both versions.
typescript-eslint’s typed rules, run on the buggy version:
16:29 error Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator @typescript-eslint/prefer-nullish-coalescing20:11 error Switch is not exhaustive. Cases not matched: "error" @typescript-eslint/switch-exhaustiveness-check36:20 error Forbidden non-null assertion @typescript-eslint/no-non-null-assertion
✖ 4 problems (4 errors, 0 warnings)Three of the four, caught directly — including the non-exhaustive switch, which switch-exhaustiveness-check flags without needing a manual never check at all, unlike relying on tsc alone. The one bug no tool here catches is Bug 1: isValidForm’s lying predicate. A type predicate’s signature is trusted completely; nothing inspects whether its body actually proves what it claims.