CodeRigor

LESSON

Narrow unknown with discriminated unions

Use a runtime test to produce a tagged decision, then let control-flow analysis narrow each branch.

External values begin as unknown when their shape has not been established. Unlike any, unknown forces you to narrow before using properties or treating a value as a particular type. A runtime comparison can provide the evidence the compiler’s control-flow analysis needs.

A discriminated union pairs a literal tag with the payload valid for that branch. StateDecision is either a valid state or an invalid reason. Consumers switch on kind, then use only the payload available in that case.

Narrow unknown with discriminated unions
start / 3
Use a runtime test to produce a tagged decision, then let control-flow analysis narrow each branch.
Narrow unknown with discriminated unions
Use a runtime test to produce a tagged decision, then let control-flow analysis narrow each branch.

The exact comparisons value==="ok" or value==="failed" narrow unknown to the two allowed literal values. The returned valid branch then preserves that state. Other inputs remain a normal invalid decision rather than an exception.

In describeState, kind selects the corresponding payload. A never default makes a later added union branch visible to the compiler if the consumer has not been updated. That protects a source relationship; it does not inspect arbitrary runtime objects that bypass the type system.

TypeScript
export type JobState = "ok" | "failed";
export interface Job { service: string; state: JobState; duration: number; }
export interface Group { service: string; count: number; failed: number; totalDuration: number; }
export interface CLIResult { code: number; stdout: string; stderr: string; }

export type StateDecision = { kind: "valid"; state: JobState } | { kind: "invalid"; reason: string };
export function decideState(value: unknown): StateDecision {
    if (value === "ok" || value === "failed") return { kind: "valid", state: value };
    return { kind: "invalid", reason: "state must be ok or failed" };
}
export function describeState(decision: StateDecision): string {
    switch (decision.kind) {
        case "valid": return "state: " + decision.state;
        case "invalid": return decision.reason;
        default: { const unreachable: never = decision; return unreachable; }
    }
}
console.log(describeState(decideState("failed")));
console.log(describeState(decideState("pending")));

A discriminant should be a stable literal, not a boolean-like string tested by truthiness. The branch payload must agree with it. Returning kind:valid for arbitrary input would break both consumers and the accepted state contract.

The compiler can flag property access on unknown. It cannot ensure a handwritten type predicate tells the truth, so this course demonstrates direct runtime checks and constructs a new result rather than asserting a shape.

Keep the external-data boundary
start / 3
Type relationships are checked before execution. JSON fields still need actual runtime checks.
Keep the external-data boundary
Type relationships are checked before execution. JSON fields still need actual runtime checks.
TypeScript
const value: unknown = "ok";
if (typeof value === "string") console.log(value.toUpperCase());
else console.log("not text");

Implement the unknown-to-decision boundary and inspect the exhaustive consumer. Next validate a complete external JSON record instead of only its state.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →