Without notes, state yesterday’s main idea and one unresolved question.
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 asstate.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.
- Add
type Category = "passive" | "active" | "connector"and use it inComponent. Fix the type errors that appear in yesterday's file. - Add the
LoadStatediscriminated union and adescribe(state: LoadState): stringfunction using aswitch. - Write
parseComponent(input: unknown): Component | nullas above. - Write
loadComponents(text: string): LoadState, handling invalid JSON, a non-array, and a bad record separately. - Test it with these inputs and record each result in
notes/day-32.md: valid record;stockas a string; missingname; misspelled category;stockof-1; the textnope;null. - Prove the
unknownrule to yourself: temporarily changeinput: unknowntoinput: anyand delete one guard. Note thattscsays nothing. Undo both changes. - Wire it to real data — read your Week 4 key out of
localStorage, pass the string toloadComponents, and render thedescribe()text on the page. - Run
npx tsc --noEmit, thennpm 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 nullis"object", so a null slips past atypeofcheck alone and the next property access throws. - Trusting
typeof x === "number"completely.NaNandInfinityare 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.
- The Handbook covers narrowing techniques this lesson did not, including one written as
value is Type. Name it, and explain what today'sisCategoryfunction is therefore doing. - Find the Handbook's statement about
unknownand every other type. Does it support the claim thatunknownblocks use until you check?
Write both answers into notes/day-32.md before you commit.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Represent loading states and parse imported JSON as unknown before validating it.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A safe parser that rejects malformed component records.
Working with AI today
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
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.