0 / 91
Week 5 · Day 32 of 91

Unions, narrowing, and unknown

TypeScript, Packages, and Tooling

Objective

Model alternatives safely and validate before use.

TypeScript is similar to design-rule checking: it catches invalid connections before runtime but cannot prove system behavior.

  • union types
  • literal types
  • typeof and property checks
  • unknown versus any

Why this matters

Yesterday's types described data you wrote yourself, so they were always true. Today you deal with the two situations that break that comfort: a value that is legitimately one of several things (a screen is idle, or loading, or showing data, or showing an error), and a value that came from outside your program and might be anything at all. On Day 27 you loaded inventory data out of localStorage and hoped it was the right shape. By the end of today you will have a parser that refuses to hope.

Union types

A union type says a value is one of several types. Write the alternatives separated by |.

export function show(stock: number | null): string {
  return stock.toFixed(2);
}

number | null means "a number, or null, and nothing else" — a parameter that may legitimately arrive empty. TypeScript then protects you from the obvious mistake:

error TS18047: 'stock' is possibly 'null'.

You declared that this value might be absent, so you may not use it as if it were present. The fix is not to delete the | null; it is to check first.

Literal types

A type can be one specific value. "passive" is a type whose only member is the string "passive". On its own that is useless; in a union it is the most useful tool in this lesson.

type Category = "passive" | "active" | "connector";

const c: Category = "passive";  // fine
const d: Category = "pasive";   // typo, caught
error TS2820: Type '"pasive"' is not assignable to type 'Category'. Did you mean '"passive"'?

Your Week 4 inventory used a free-text category string. Every typo in it was a silent bug that made a component vanish from a filter. A literal union turns that whole class of bug into a compile error.

Literal unions are the connector keying

A three-position mode switch has exactly three valid positions; there is no "between". A keyed connector physically cannot go in backwards. A literal union is that keying expressed in code — the invalid state is not rejected at runtime, it is unrepresentable in the design. And, like keying, it says nothing about whether the mode you selected is the right one.

The strongest form is a discriminated union: several object shapes that share one literal property telling them apart. This is how you model a screen's loading state.

export type LoadState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "ready"; items: Component[] }
  | { status: "error"; message: string };

The shared status property is the discriminant. Note what this makes impossible: there is no way to build a value that is "loading" and carries an error message, or "ready" with no items. Separate isLoading and errorText variables could contradict each other. These cannot.

Reaching for a property before establishing which member you have is an error:

export function badDescribe(state: LoadState): string {
  return state.message;
}
error TS2339: Property 'message' does not exist on type 'LoadState'.
  Property 'message' does not exist on type '{ status: "idle"; }'.

Read the second line carefully — it names the specific member that lacks the property. That is TypeScript showing its work.

Narrowing with typeof and property checks

Narrowing is the compiler watching your checks and shrinking the type inside the block where the check held. You already write these checks; the new part is that they now change what is allowed.

function label(stock: number | null): string {
  if (stock === null) {
    return "unknown";     // here stock is null
  }
  return stock.toFixed(0); // here stock is number — no error
}

Four narrowing tools cover almost everything:

  • typeof x === "string" (or "number", "boolean", "object") — the Day 15 operator, now doing double duty.
  • x === null, or a literal comparison such as state.status === "ready".
  • "name" in x — asks whether an object has that property, and narrows to the members that do.
  • Array.isArray(x) — the reliable way to prove a value is an array.

A switch on the discriminant narrows in every branch, and is the idiomatic way to handle a discriminated union:

export function describe(state: LoadState): string {
  switch (state.status) {
    case "idle":
      return "Nothing loaded yet";
    case "loading":
      return "Loading...";
    case "ready":
      return `${state.items.length} components loaded`;
    case "error":
      return `Failed: ${state.message}`;
  }
}

state.items is legal in the "ready" branch and nowhere else. Add a fifth member to LoadState later and the compiler flags this function, because it can no longer prove every path returns a string.

unknown versus any

Both types mean "I do not know what this is". They differ in what happens next, and the difference is the whole point of today.

any switches checking off. Every property access, call, and assignment is permitted. It is contagious: anything derived from an any is also unchecked.

unknown is the honest version. You may hold the value and pass it around, but you may not use it until you have proven what it is.

export function useUnknown(text: string): string {
  const raw: unknown = JSON.parse(text);
  return raw.name;
}
error TS18046: 'raw' is of type 'unknown'.

That error is the feature: unknown forces validation before use. Write the same line with any and it compiles silently, then crashes at runtime with Cannot read properties of undefined.

This matters right now because JSON.parse returns any. Every value your app reads from localStorage, from a file, or (from Week 6) from a network request arrives through a hole in the type system. Annotating the result as unknown plugs it.

The parcel at the door

any is signing for a parcel and immediately assuming it contains what the label says. unknown is signing for it, putting it on the bench, and opening it before you act on the contents. Neither approach changes what is in the box — only one of them finds out first.

`as` is not validation

const item = raw as Component; compiles happily and checks nothing at runtime. A type assertion overrules the compiler, and it is a lie the moment the data disagrees. Use real checks instead.

Walkthrough

In src/inventory.ts, add the Category literal union and change Component.category to use it. Then create src/parse.ts with a validator that turns an unknown into a Component or null:

import type { Component } from "./inventory.ts";

function isCategory(value: unknown): value is Component["category"] {
  return value === "passive" || value === "active" || value === "connector";
}

export function parseComponent(input: unknown): Component | null {
  if (typeof input !== "object" || input === null) return null;
  if (!("id" in input) || typeof input.id !== "string") return null;
  if (!("name" in input) || typeof input.name !== "string") return null;
  if (!("category" in input) || !isCategory(input.category)) return null;
  if (!("stock" in input) || typeof input.stock !== "number") return null;
  if (!Number.isFinite(input.stock) || input.stock < 0) return null;
  return { id: input.id, name: input.name, category: input.category, stock: input.stock };
}

Every line is a check, and each narrows the type further, which is why the final return compiles without a single assertion. Two details. typeof null === "object" — the 1995 bug from Day 15 — is why the first line also tests input === null. And Number.isFinite is there because typeof NaN is "number": proof that the compiler's job ends where values begin.

Now the loader, returning a LoadState:

export function loadComponents(text: string): LoadState {
  let raw: unknown;
  try {
    raw = JSON.parse(text);
  } catch {
    return { status: "error", message: "Stored data is not valid JSON" };
  }
  if (!Array.isArray(raw)) {
    return { status: "error", message: "Stored data is not a list" };
  }
  const items: Component[] = [];
  for (const entry of raw) {
    const parsed = parseComponent(entry);
    if (parsed === null) {
      return { status: "error", message: "A record was malformed" };
    }
    items.push(parsed);
  }
  return { status: "ready", items };
}

Feed it three inputs:

console.log(describe(loadComponents('[{"id":"r1","name":"R","category":"passive","stock":5}]')));
console.log(describe(loadComponents('[{"id":"r1","name":"R","category":"passive","stock":"5"}]')));
console.log(describe(loadComponents('nope')));
1 components loaded
Failed: A record was malformed
Failed: Stored data is not valid JSON

Checkpoint

Say which of those three results the compiler caught and which the running code caught. The answer is none and all three — every one of those inputs is a string as far as tsc is concerned. Static checking got you the shape of the guard; the guard did the work.

How to use AI today

Today's mode is reviewer. A code review should produce specific, actionable findings with evidence — an input that gets through, with the line that lets it — not praise and not a rewrite. Today the most valuable review is an attack.

Reviewer mode, once your parser runs

"Try to break my type narrowing with malformed inputs. Report cases the compiler cannot detect." Do not accept a suggested fix. Take each proposed input, feed it to your parser yourself, and see what happens. Findings you reproduced are evidence; findings you did not are guesses.

Your turn

Build the deliverable: a parser that rejects malformed component records.

  1. Add type Category = "passive" | "active" | "connector" and use it in Component. Fix the type errors that appear in yesterday's file.
  2. Add the LoadState discriminated union and a describe(state: LoadState): string function using a switch.
  3. Write parseComponent(input: unknown): Component | null as above.
  4. Write loadComponents(text: string): LoadState, handling invalid JSON, a non-array, and a bad record separately.
  5. Test it with these inputs and record each result in notes/day-32.md: valid record; stock as a string; missing name; misspelled category; stock of -1; the text nope; null.
  6. Prove the unknown rule to yourself: temporarily change input: unknown to input: any and delete one guard. Note that tsc says nothing. Undo both changes.
  7. Wire it to real data — read your Week 4 key out of localStorage, pass the string to loadComponents, and render the describe() text on the page.
  8. Run npx tsc --noEmit, then npm run build, then commit.

You are done when

Every malformed input in your table produces an error state and no crash, and no as or any appears anywhere in the file.

Common pitfalls

  • Forgetting input === null. typeof null is "object", so a null slips past a typeof check alone and the next property access throws.
  • Trusting typeof x === "number" completely. NaN and Infinity are numbers. Range and finiteness are your job.
  • Silencing an error with as. The compiler stops complaining and the bug survives intact.
  • Validating only at the top level. A valid array of invalid objects is still invalid. Check each record, as the loop above does.

Verify it yourself

Open today's reference, the TypeScript Handbook, and read Narrowing.

  1. The Handbook covers narrowing techniques this lesson did not, including one written as value is Type. Name it, and explain what today's isCategory function is therefore doing.
  2. Find the Handbook's statement about unknown and every other type. Does it support the claim that unknown blocks use until you check?

Write both answers into notes/day-32.md before you commit.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Represent loading states and parse imported JSON as unknown before validating it.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A safe parser that rejects malformed component records.

Working with AI today

AI as skeptical reviewer

Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.

Try to break my type narrowing with malformed inputs. Report cases the compiler cannot detect.

References

End-of-day quiz

Q1 Why prefer unknown over any for untrusted input?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.