A union describes a value that can have one of several types. Narrowing means proving which possibilities remain at a particular point in execution. Use ordinary runtime checks such as typeof, null checks, equality and discriminant properties. The checker follows those branches; a type assertion merely asks it to trust you.
Model states that cannot contradict each other
type LoadState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; titles: readonly string[] }
| { kind: "error"; message: string };
function label(state: LoadState): string {
switch (state.kind) {
case "idle": return "Choose a topic";
case "loading": return "Loading";
case "ready": return `${state.titles.length} topics`;
case "error": return state.message;
default: {
const unreachable: never = state;
return unreachable;
}
}
}
console.log(label({ kind: "ready", titles: ["Types"] }));
export {};
The output is 1 topics. The simple message is grammatically rough for one item; add plural handling as an exercise. More importantly, a ready state requires titles and an error state requires a message. Separate booleans such as loading, hasError and hasResults can express contradictory combinations. A discriminated union makes the intended alternatives explicit. The never assignment helps detect a missing switch branch when a new variant is added.
Validate unknown data
Save this independent example and compile it with the strict settings from the setup lesson:
type Session = { topic: string; minutes: number };
function parseSession(value: unknown): Session {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Expected a session object");
}
if (!("topic" in value) || typeof value.topic !== "string" || !value.topic.trim()) {
throw new Error("Invalid topic");
}
if (!("minutes" in value) || typeof value.minutes !== "number" ||
!Number.isInteger(value.minutes) || value.minutes < 1 || value.minutes > 180) {
throw new Error("Invalid duration");
}
return { topic: value.topic.trim(), minutes: value.minutes };
}
const external: unknown = JSON.parse('{"topic":" Types ","minutes":25}');
console.log(parseSession(external));
export {};
Returning a fresh object creates a clean domain boundary. Assign JSON.parse's result to unknown even though the library signature is permissive. A generic fetch helper returning Promise<T> without validating a response does not prove that T arrived over the network. A schema library can automate validation in a larger application, but the obligation remains the same.
A user-defined type predicate is also a promise: a buggy predicate can convince the checker of something false. Keep guards small and test negative inputs. Truthiness alone is often too broad; a valid zero, false or empty string can be excluded unintentionally. With optional properties, ask whether absence, an explicit undefined value and null have different meanings in your API.
Exercise: add an empty-success variant or a plural-aware ready message. Add an invalid external record whose duration is "25", then ensure it fails parsing.
Solution guidance: update every switch case rather than appending a blanket default message. Check unknown fields before using them, and convert strings only when the input contract explicitly permits conversion.
Reference: Narrowing.
Previous: TypeScript Setup and Everyday Types. Next: Interfaces, Generics and Typed Collections.

