TypeScript / Basic / Lesson 3
Unions, narrowing, and nullability
Represent alternatives honestly and use runtime observations to justify property access, including explicit handling of missing values.
On this page
Lesson goals
- Narrow unions with typeof, equality, and discriminants.
- Handle null and undefined without assertion-based shortcuts.
- Use never to expose omitted variants.
A union describes one of several possibilities
A value of string | number may be either alternative, so code can initially use only operations valid for both. The union does not merge every member property into one object. Accessing a string-only method requires evidence that the current value is a string. TypeScript control-flow analysis uses conditions and assignments to refine the apparent type at a particular location. That knowledge can change after reassignment or across code that may mutate shared state. Keep the runtime test close to the operation it justifies, and avoid weakening the union simply to hide a missing case.
Observe the actual primitive before using its methods
The first function branches on typeof and returns the same result type from both paths. Its string branch can safely call toUpperCase, while the numeric branch can call toFixed. Type annotations are erased, but the typeof test remains JavaScript and performs the observation at runtime. Remember that typeof null is "object", so object-shaped unknown values need a separate null check. Arrays also require their own shape test rather than being distinguished by typeof alone. A successful primitive test does not validate unrelated application constraints such as a numeric limit.
export {};
function describe(value: string | number): string {
if (typeof value === "string") { return value.toUpperCase(); }
return value.toFixed(1);
}
console.log(describe("code"));
console.log(describe(5));
CODE
5.0
The preserved runtime branch selects the appropriate method. Both branches return strings.
Absence is different from an empty valid value
With strictNullChecks, null and undefined must be accounted for when included in a type. An optional property can be absent, and reading it commonly produces a possibly undefined value. Optional chaining stops access when its base is nullish; it does not validate the rest of an arbitrary payload. Nullish coalescing supplies a default for null or undefined while preserving zero, false, and an empty string. The non-null assertion operator only changes checker treatment and performs no runtime check. Prefer a guard, a meaningful fallback, or an explicit failure when absence is allowed by the boundary.
Tie fields to their valid state
A discriminated union uses a shared property with different literal values to identify alternatives. Put a count only on the ready state and an error message only on the failed state instead of combining optional fields that permit contradictory combinations. The second example switches on that tag. assertNever makes an omitted future variant visible during checking because a remaining variant cannot be passed as never. The throw is a runtime fallback if the invariant is violated by unchecked JavaScript or unvalidated data, not a validation guarantee for incoming values.
export {};
type State = { kind: "ready"; count: number } | { kind: "failed"; reason: string };
function assertNever(value: never): never { throw new Error("unexpected state"); }
function summary(state: State): string {
switch (state.kind) {
case "ready": return `ready:${state.count}`;
case "failed": return `error:${state.reason}`;
default: return assertNever(state);
}
}
console.log(summary({ kind: "ready", count: 3 }));
console.log(summary({ kind: "failed", reason: "disk" }));
ready:3
error:disk
Each tag exposes its matching fields. A new unhandled variant would fail the never check.
A predicate implementation must earn its claim
A user-defined type predicate can tell the checker that a value is a particular type when true. The compiler does not prove that every handwritten predicate accurately validates that type. A predicate that always returns true creates false confidence just like an unjustified assertion. At an unknown boundary, inspect every required field and relevant domain rule, then return validated data or a clear failure. Avoid retaining a narrow assumption across mutable aliases when another operation can change the discriminant or required fields. Test guards with malformed objects as well as valid values. Narrowing is a reasoning tool, not automatic trust in external data.
Common errors and fixes
A property does not exist on a union.
- Why it happens
- Only one alternative contains that property.
- How to fix it
- Narrow to the relevant alternative before access.
- Prevent it next time
- Model meaningful discriminants and handle every case.
A non-null assertion still allows a runtime failure.
- Why it happens
- The assertion added no actual check.
- How to fix it
- Guard absence or return a documented failure.
- Prevent it next time
- Test missing values at the entry boundary.
A custom type guard accepts malformed objects.
- Why it happens
- Its boolean implementation does not validate the claimed shape.
- How to fix it
- Check required properties and domain constraints.
- Prevent it next time
- Use negative fixtures to test predicate soundness.
Check your understanding
1. Add a paused variant with no count or reason to State. Explain the checker failure and the deliberate repair.
Show worked solution
Without another switch arm, the default branch still contains the paused alternative and cannot pass it to assertNever. Add case "paused" with its documented description. Do not remove the never check or assert the state away, because its purpose is to force review when the state model grows. Include a runtime test for the new branch.
2. Compare value || 10 and value ?? 10 for value zero and value undefined. Choose one for a count where zero is valid.
Show worked solution
For zero, || returns ten while ?? returns zero. For undefined, both return ten. Use ?? when only absence should trigger the default, then separately validate the resulting count domain. This preserves valid zero without pretending that a default expression checks fractions, infinities, or untrusted object shapes.
Key takeaways
- Narrow before using alternative-specific members.
- Represent absence explicitly and preserve valid empty values.
- Validate predicates as executable code.