3. Unions and narrowing
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: the files examples/l03_* and errors/l03_*, and the C# and Java sides in compare/l03_switch.cs, compare/l03_cast.cs, compare/L03Cast.java and compare_fail/L03Sealed.java.
C# and Java model “one of several things” with a class hierarchy: an abstract base, one subclass per case, and a virtual method or a pattern match to tell them apart. JavaScript has no such hierarchy for most of its values: a fret is a number or the string 'open', an event is an object whose kind property says what it is. TypeScript describes those values with union types, and follows the checks in your code to know which member a value is at each line. That second part, narrowing, is what this lesson is about (handbook: narrowing).
Union types
Section titled “Union types”A | B is a value that is an A or a B. Before a check, only what every member has is allowed:
type Fret = number | 'open' | 'muted';
function describe(fret: Fret): string { return `fret ${fret.toFixed(0)}`;}
console.log(describe(3), describe('open'));> npx tsc -p out/tsconfig.l03_union_members.json --prettyerrors/l03_union_members.ts:5:23 - error TS2339: Property 'toFixed' does not exist on type 'Fret'. Property 'toFixed' does not exist on type '"muted"'.
5 return `fret ${fret.toFixed(0)}`; ~~~~~~~
Found 1 error in errors/l03_union_members.ts:5> node errors/l03_union_members.tserrors/l03_union_members.ts:5 return `fret ${fret.toFixed(0)}`; ^
TypeError: fret.toFixed is not a function
Node.js v24.21.0number has toFixed, 'open' and 'muted' don’t, so Fret doesn’t either. The message names one member that lacks the property. A union of literal types, 'open' | 'muted', is also how TypeScript writes what C# and Java would make an enum, without the runtime object that Node.js refuses to strip (lesson 1).
Narrowing
Section titled “Narrowing”import { show } from './show.ts';
// A union: a fret is a number, or 'open', or 'muted'type Fret = number | 'open' | 'muted';
function describe(fret: Fret): string { if (typeof fret === 'number') { return `fret ${fret.toFixed(0)}`; // here fret is a number } return fret === 'open' ? 'open string' : 'not played'; // here fret is 'open' | 'muted'}show('describe(3)', describe(3));show("describe('muted')", describe('muted'));
// Truthiness narrows too, and 0 is falsy: the open string played at fret 0 disappearsfunction label(fret: number | undefined): string { if (fret) return `fret ${fret}`; return 'no fret';}show('label(0)', label(0));show('label(undefined)', label(undefined));
// in, instanceof and Array.isArrayinterface Note { pitch: number;}interface Chord { pitches: number[];}function lowest(event: Note | Chord): number { return 'pitch' in event ? event.pitch : Math.min(...event.pitches);}show('lowest({ pitch: 40 })', lowest({ pitch: 40 }));show('lowest({ pitches: [52, 45] })', lowest({ pitches: [52, 45] }));
function message(error: unknown): string { if (error instanceof Error) return error.message; if (Array.isArray(error)) return `${error.length} errors`; return String(error);}show("message(new RangeError('fret 25'))", message(new RangeError('fret 25')));show("message(['a', 'b'])", message(['a', 'b']));
// Control flow: after a return or a throw, the rest of the function knows morefunction parseFret(text: string): Fret { if (text === 'o') return 'open'; if (text === 'x') return 'muted'; const fret = Number(text); if (!Number.isInteger(fret) || fret < 0) throw new RangeError(`not a fret: ${text}`); return fret;}show("'x32010'.split('').map(parseFret)", 'x32010'.split('').map(parseFret));describe(3) 'fret 3'describe('muted') 'not played'label(0) 'no fret'label(undefined) 'no fret'lowest({ pitch: 40 }) 40lowest({ pitches: [52, 45] }) 45message(new RangeError('fret 25')) 'fret 25'message(['a', 'b']) '2 errors''x32010'.split('').map(parseFret) [ 'muted', 3, 2, 0, 1, 0 ]Each check that JavaScript can run becomes information for tsc:
| Check | Narrows to | C# / Java equivalent |
|---|---|---|
typeof fret === 'number' |
number, and 'open' | 'muted' in the else |
fret is int / fret instanceof Integer |
fret === 'open' |
the literal type 'open' |
== on a constant |
if (fret) |
removes undefined, null, and the literal types 0, '', false |
— |
'pitch' in event |
the members that declare pitch |
— |
error instanceof Error |
Error |
error is Exception e / error instanceof Exception e |
Array.isArray(error) |
any[] |
— |
return, throw |
the rest of the function, without the cases that left | definite assignment, flow analysis |
Two of those checks deserve a warning. Truthiness narrows number | undefined to number, and the if (fret) in label also sends the fret 0, the open string, to the 'no fret' branch: tsc accepts it, because 0 is a number, and the program is wrong. The rules are those of JavaScript lesson 2; compare with !== undefined whenever 0 or '' is a valid value. in checks a property at run time, and structural typing lets an object have more properties than its type says: a Chord object that also carries a pitch would take the Note branch. in is reliable when the members of the union can’t have each other’s properties, which is what the next section builds.
tsc follows the code the way C#’s definite assignment analysis does, through return, throw, &&, || and ?:: in parseFret, fret is a number after the two early returns, and the throw guarantees that it is a non-negative integer, a fact that the type number can’t express.
Discriminated unions
Section titled “Discriminated unions”When every member of a union has the same property with a different literal type, checking that property narrows to one member. That property is the discriminant, here kind:
import { show } from './show.ts';
// A discriminated union: every member has a kind, with a different literal typetype MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch} for ${event.beats}`; case 'chord': return `chord of ${event.pitches.length} for ${event.beats}`; case 'rest': return `rest for ${event.beats}`; default: return assertNever(event); // event is never here: every kind is handled }}
const bar: MusicEvent[] = [ { kind: 'chord', pitches: [48, 52, 55], beats: 2 }, { kind: 'note', pitch: 60, beats: 1 }, { kind: 'rest', beats: 1 },];for (const event of bar) show(event.kind, describe(event));
// The check that tsc did at compile time still runs, for data that didn't go through tscconst fromServer = JSON.parse('{"kind": "tie", "beats": 1}') as MusicEvent;try { describe(fromServer);} catch (err) { show('describe(fromServer)', err instanceof Error ? err.message : err);}chord 'chord of 3 for 2'note 'note 60 for 1'rest 'rest for 1'describe(fromServer) 'unexpected event: {"kind":"tie","beats":1}'Inside case 'note':, event is { kind: 'note'; pitch: number; beats: number }, and event.pitch compiles; in case 'rest': it wouldn’t. After the three cases, nothing is left: event has the type never, and assertNever(event) compiles because a never is assignable to the parameter never. At run time the default branch still exists, and it catches what the types didn’t: fromServer was asserted to be a MusicEvent, arrived with the kind 'tie', and reached assertNever.
A discriminated union is the TypeScript version of a closed hierarchy, a C# abstract record with its derived records, or a Java sealed interface with its record implementations. The data is plain objects, which is what JSON.parse returns and what a server sends.
Exhaustiveness
Section titled “Exhaustiveness”Add a member, and every switch that doesn’t handle it should fail to compile. With assertNever in the default, or with a declared return type, it does:
// A new kind of event: the two functions that don't handle it are now errorstype MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number } | { kind: 'tie'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch}`; case 'chord': return `chord of ${event.pitches.length}`; case 'rest': return 'rest'; default: return assertNever(event); }}
function beatsOf(event: MusicEvent): number { switch (event.kind) { case 'note': case 'chord': case 'rest': return event.beats; }}
console.log(describe({ kind: 'tie', beats: 1 }), beatsOf({ kind: 'tie', beats: 1 }));> npx tsc -p out/tsconfig.l03_exhaustive.json --prettyerrors/l03_exhaustive.ts:22:26 - error TS2345: Argument of type '{ kind: "tie"; beats: number; }' is not assignable to parameter of type 'never'.
22 return assertNever(event); ~~~~~
errors/l03_exhaustive.ts:26:38 - error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
26 function beatsOf(event: MusicEvent): number { ~~~~~~
Found 2 errors in the same file, starting at: errors/l03_exhaustive.ts:22> node errors/l03_exhaustive.tserrors/l03_exhaustive.ts:10 throw new Error(`unexpected event: ${JSON.stringify(value)}`); ^
Error: unexpected event: {"kind":"tie","beats":1}
Node.js v24.21.0The first error says that a tie event reaches a parameter that accepts nothing. The second comes from the return type: beatsOf promises a number, and a tie event would fall through the switch and return undefined. Without assertNever, a function with no declared return type, or one that returns void, compiles without a message (exhaustiveness checking).
The same change in C#, with a switch expression over records, is a warning:
> dotnet run l03_switch.cscompare/l03_switch.cs(10,43): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.chord of 3note 60SwitchExpressionExceptionC# can’t know that no other class derives from MusicEvent, so it asks for a _ case, builds anyway, and throws SwitchExpressionException on the Tie. Java’s sealed interfaces close the hierarchy, and a switch with patterns over one is checked as TypeScript checks a union:
> javac L03Sealed.javaL03Sealed.java:10: error: the switch expression does not cover all possible input values return switch (e) { ^1 error| C# | Java | TypeScript | |
|---|---|---|---|
| Closed set of cases | no closed hierarchies in C# 14: a _ case is expected |
sealed interface … permits |
a union type |
| Missing case | warning CS8509, SwitchExpressionException at run time |
compile error | error, if the switch ends in assertNever or the function declares its return type |
| Data from outside | deserialized into the classes | deserialized into the records | plain objects: the discriminant must be checked |
as checks nothing
Section titled “as checks nothing”C# and Java casts are checked when they run. A TypeScript type assertion, value as T, is a message to tsc and disappears with the other types:
// examples/l03_guards.ts, lines 11-13const trusted = JSON.parse('{"px": 1, "py": 2}') as CameraState;show('trusted.pz', trusted.pz);show('trusted.pz * 2', trusted.pz * 2);trusted.pz undefinedtrusted.pz * 2 NaN> dotnet run l03_cast.cs(CameraState)parsed: InvalidCastExceptionparsed as CameraState: Trueparsed is CameraState: False> java L03Cast.java(CameraState) parsed: ClassCastExceptionparsed instanceof CameraState: falseC#’s (CameraState)parsed throws InvalidCastException, as returns null, and Java’s cast throws ClassCastException: the runtime knows the class of every object. A JavaScript object parsed from JSON has no class to compare with, and as doesn’t try. tsc only refuses an assertion between two types that don’t overlap at all, and as unknown as T goes through unknown to get past that refusal (type assertions). The number NaN that came out of trusted.pz * 2 is the kind of value that travels far from the line that produced it.
Type predicates and assertion functions
Section titled “Type predicates and assertion functions”A check that tsc can’t read in one expression goes into a function whose return type says what it proves:
import { attempt, show } from './show.ts';
interface CameraState { px: number; py: number; pz: number;}
// as is not a cast: it converts nothing and checks nothingconst trusted = JSON.parse('{"px": 1, "py": 2}') as CameraState;show('trusted.pz', trusted.pz);show('trusted.pz * 2', trusted.pz * 2);
// A type predicate: a function that returns a boolean, and tells tsc what true meansfunction isCameraState(value: unknown): value is CameraState { return ( typeof value === 'object' && value !== null && 'px' in value && typeof value.px === 'number' && // after 'px' in value, tsc knows value has a px property of type unknown 'py' in value && typeof value.py === 'number' && 'pz' in value && typeof value.pz === 'number' );}
function restore(saved: string): CameraState { const value: unknown = JSON.parse(saved); return isCameraState(value) ? value : { px: 0, py: 0, pz: 100 };}show("restore('{\"px\":1,\"py\":2}')", restore('{"px":1,"py":2}'));show("restore('{\"px\":1,\"py\":2,\"pz\":3}')", restore('{"px":1,"py":2,"pz":3}'));
// An assertion function: it returns only if the condition holds, and throws otherwisetype Mode = 'ionian' | 'dorian' | 'phrygian';const modes: readonly string[] = ['ionian', 'dorian', 'phrygian'];function assertMode(value: string): asserts value is Mode { if (!modes.includes(value)) throw new RangeError(`unknown mode: ${value}`);}function brightness(mode: Mode): number { return modes.length - modes.indexOf(mode);}const fromUrl = new URLSearchParams('?mode=dorian').get('mode') ?? 'ionian';assertMode(fromUrl);show('brightness(fromUrl)', brightness(fromUrl)); // fromUrl is a Mode after the assertionattempt("assertMode('locrian')", () => assertMode('locrian'));
// tsc trusts a predicate without reading it: a wrong one is a lie that compilesfunction isCameraStateLie(value: unknown): value is CameraState { return value !== null;}const lie: unknown = JSON.parse('"not a camera"');if (isCameraStateLie(lie)) { attempt('lie.px.toFixed(1)', () => lie.px.toFixed(1));}trusted.pz undefinedtrusted.pz * 2 NaNrestore('{"px":1,"py":2}') { px: 0, py: 0, pz: 100 }restore('{"px":1,"py":2,"pz":3}') { px: 1, py: 2, pz: 3 }brightness(fromUrl) 2assertMode('locrian') RangeError: unknown mode: locrianlie.px.toFixed(1) TypeError: Cannot read properties of undefined (reading 'toFixed')value is CameraState, a type predicate, returns aboolean, and in the branch where it returnedtrue, the argument is aCameraState. InsideisCameraState, each'px' in valuenarrowsvalueto an object with apxproperty of typeunknown, whichtypeof value.px === 'number'then narrows tonumber: the function compiles without a singleas.asserts value is Mode, an assertion function, returns only when the condition holds and throws otherwise; after the call, the variable has the narrower type for the rest of the scope, like after athrowin the function itself. The function must be declared with an explicit type, afunctiondeclaration or an annotatedconst, fortscto use it.tscdoesn’t read the body of a predicate.isCameraStateLiechecks only that the value isn’tnull, compiles, and makestscbelieve that a string is a camera. A predicate is an assertion with a function around it: the checks inside are what make it true, and they deserve their own tests.
That is the border pattern for data from outside the program, JSON.parse, localStorage, fetch, a SignalR message: an unknown on the way in, a predicate or a conversion that checks it, and precise types inside.
In GuitarAlchemist/ga: statuses that the type rules out
Section titled “In GuitarAlchemist/ga: statuses that the type rules out”GA’s Prime Radiant, a governance graph in ga-react-components, declares the health statuses a node can have in types.ts, line 34:
export type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';ForceRadiant.tsx, lines 720-729, picks a prediction from that status, and also tests 'ok' and 'critical'. tsc 7.0.2 reports both, among the errors of lesson 1; the course reproduces the function with the same type:
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';interface GovernanceNode { id: string; healthStatus?: GovernanceHealthStatus;}
function prediction(node: GovernanceNode): string { const status = node.healthStatus ?? 'unknown'; if (status === 'healthy' || status === 'ok') return 'stable'; if (status === 'warning') return 'at risk'; if (status === 'error' || status === 'critical') return 'failing'; return 'uncertain';}
console.log(prediction({ id: 'policy-7' }));> npx tsc -p out/tsconfig.l03_ga_health.json --prettyerrors/l03_ga_health.ts:10:31 - error TS2367: This comparison appears to be unintentional because the types '"contradictory" | "error" | "unknown" | "warning"' and '"ok"' have no overlap.
10 if (status === 'healthy' || status === 'ok') return 'stable'; ~~~~~~~~~~~~~~~
errors/l03_ga_health.ts:12:29 - error TS2367: This comparison appears to be unintentional because the types '"contradictory" | "unknown"' and '"critical"' have no overlap.
12 if (status === 'error' || status === 'critical') return 'failing'; ~~~~~~~~~~~~~~~~~~~~~
Found 2 errors in the same file, starting at: errors/l03_ga_health.ts:10The messages show narrowing at work: after status === 'healthy' failed, status can no longer be 'healthy', and after the warning line it can no longer be 'warning' either. Neither comparison can ever be true for a value of that type. At run time, though, it can, because of where the data comes from. DataLoader.ts, lines 276-278, receives the NodeChanged message of the SignalR JavaScript client with healthStatus: string, and passes it on as data as unknown as GovernanceNode:
import { show } from './show.ts';
// types.ts, line 34: the statuses the front end knowstype GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';interface GovernanceNode { id: string; healthStatus?: GovernanceHealthStatus;}
// DataLoader.ts, lines 276-278: a SignalR message typed with a string, passed on with a double assertionfunction onNodeChanged(data: { nodeId: string; healthStatus: string }): GovernanceNode { return data as unknown as GovernanceNode;}
// ForceRadiant.tsx, lines 720-729, reduced: the statuses 'ok' and 'critical' can't be in the unionfunction prediction(node: GovernanceNode): string { const status: string = node.healthStatus ?? 'unknown'; // widened to string, or tsc reports TS2367 below if (status === 'healthy' || status === 'ok') return 'stable'; if (status === 'warning') return 'at risk'; if (status === 'error' || status === 'critical') return 'failing'; return 'uncertain';}
const node = onNodeChanged({ nodeId: 'policy-7', healthStatus: 'critical' });show('node.healthStatus', node.healthStatus);show('prediction(node)', prediction(node));const known: readonly string[] = ['error', 'warning', 'healthy', 'unknown', 'contradictory'];show('known.includes(node.healthStatus)', known.includes(node.healthStatus ?? 'unknown'));node.healthStatus 'critical'prediction(node) 'failing'known.includes(node.healthStatus) falseA 'critical' from the server reaches a GovernanceNode whose type says it can’t be 'critical', and the comparison that tsc calls unintentional is the one that handles it. Two readings are possible, and the code doesn’t say which is right: the server sends ok and critical (to verify in GA’s hub), and the union is missing two members; or it doesn’t, and the comparisons are dead code. Either way, the double assertion is where the type stopped describing the data. The third exercise converts the status at that border instead. ga-react-components contains 34 as unknown as, and the same file restores the camera with JSON.parse(saved) as { px: number; … }, line 3558, the pattern of the as section: a saved value that lacks a coordinate would pass undefined to fg.cameraPosition (to verify in a browser); the second exercise checks it.
Union order in messages
Section titled “Union order in messages”TypeScript 5.9.3, on the same GA file, printed the first union as "warning" | "error" | "unknown" | "contradictory"; 7.0.2 prints "contradictory" | "error" | "unknown" | "warning". Neither is the order of the declaration. Up to 6.0, union members were sorted by internal type IDs, assigned in the order the checker met the types; TypeScript 7 checks files in parallel and sorts types by their content instead, so that the output doesn’t depend on which thread saw a type first (6.0 release notes, --stableTypeOrdering). Don’t write a test that compares the text of a union in a message or a .d.ts across versions.
On this site: a union inferred from JSON
Section titled “On this site: a union inferred from JSON”This site’s astro.config.mjs starts with // @ts-check, which asks tsc to check a JavaScript file, and imports the generated streeling-sidebar.json into Starlight’s sidebar. Running tsc 7.0.2 on the site at that commit, with the site’s own tsconfig.json, reports it:
astro.config.mjs(146,5): error TS2322: Type '{ label: string; collapsed: true; items: ({ label: string; translations: { fr: string; es: string; }; slug: string; collapsed?: undefined; items?: undefined; } | { label: string; translations: { es: string; fr?: undefined; }; slug: string; collapsed?: undefined; items?: undefined; } | { ...; })[]; }' is not assignable to type 'SidebarItemUserConfig'. Types of property 'items' are incompatible. […] Types of property 'translations' are incompatible. Type '{ es: string; fr?: undefined; }' is not assignable to type 'Record<string, string>'. Property '"fr"' is incompatible with index signature. Type 'undefined' is not assignable to type 'string'.A JSON import gets the type of its content, inferred as for an object literal. The array holds entries of different shapes, so its element type is a union, and tsc normalizes object literal types in a union: a property that one member has and another doesn’t is added to the other as optional and undefined, so that it can be read on every member. The Journal entry has only a Spanish translation, gains fr?: undefined, and no longer fits a Record<string, string>. Nothing is wrong at run time: the Astro build doesn’t run tsc, and Starlight validates the sidebar when it loads. It shows the limit of inference for data: a type written by the program, checked against the data at the border, says what the code expects, where an inferred one only says what the file happened to contain.
Key takeaways
Section titled “Key takeaways”- A union allows only what all its members have; a check narrows it, and
tscfollowstypeof,===, truthiness,in,instanceof,returnandthrow. - Truthiness narrowing also removes
0and'';incan be fooled by extra properties. - A discriminated union, a common literal property, is TypeScript’s closed hierarchy. End its
switchwithassertNever, or declare the return type, and a new member is a compile error, as with Java’s sealed interfaces and unlike C#’s warning. aschecks nothing, where C# and Java casts throw;as unknown asremoves the last checktscmakes.- A type predicate or an assertion function narrows what
tsccan’t follow, andtsctrusts its body blindly. - Data from outside comes in as
unknownand is checked or converted once, at the border. - Don’t depend on the order of union members in messages or declaration files.
Exercises
Section titled “Exercises”- Add a
{ kind: 'tie'; beats: number }event to theMusicEventoferrors/l03_exhaustive.ts, make both functions compile, and total the beats of a bar.
Solution
type MusicEvent = | { kind: 'note'; pitch: number; beats: number } | { kind: 'chord'; pitches: number[]; beats: number } | { kind: 'rest'; beats: number } | { kind: 'tie'; beats: number };
function assertNever(value: never): never { throw new Error(`unexpected event: ${JSON.stringify(value)}`);}
function describe(event: MusicEvent): string { switch (event.kind) { case 'note': return `note ${event.pitch} for ${event.beats}`; case 'chord': return `chord of ${event.pitches.length} for ${event.beats}`; case 'rest': return `rest for ${event.beats}`; case 'tie': return `tie for ${event.beats}`; default: return assertNever(event); }}
// beatsOf needs no switch at all: every member has beatsfunction beatsOf(event: MusicEvent): number { return event.beats;}
const bar: MusicEvent[] = [ { kind: 'note', pitch: 60, beats: 2 }, { kind: 'tie', beats: 1 }, { kind: 'rest', beats: 1 },];console.log(bar.map(describe), bar.reduce((sum, event) => sum + beatsOf(event), 0));[ 'note 60 for 2', 'tie for 1', 'rest for 1' ] 4beats is common to all members, so event.beats compiles without narrowing, and beatsOf has no switch left to forget a case in.
- GA saves its camera as six numbers,
px,py,pzfor the position andlx,ly,lzfor the target. WriterestoreCamera(saved: string | null): CameraState | undefined, which returnsundefinedfor a missing value, invalid JSON, a missing coordinate, or a coordinate that isn’t a finite number, withoutas.
Solution
interface CameraState { px: number; py: number; pz: number; lx: number; ly: number; lz: number;}const keys = ['px', 'py', 'pz', 'lx', 'ly', 'lz'] as const;
function isCameraState(value: unknown): value is CameraState { if (typeof value !== 'object' || value === null) return false; const record: { [key: string]: unknown } = { ...value }; return keys.every((key) => typeof record[key] === 'number' && Number.isFinite(record[key]));}
// undefined when nothing usable is saved: the caller keeps its default camerafunction restoreCamera(saved: string | null): CameraState | undefined { if (saved === null) return undefined; try { const value: unknown = JSON.parse(saved); return isCameraState(value) ? value : undefined; } catch { return undefined; // not JSON at all }}
const good = JSON.stringify({ px: 0, py: 50, pz: 300, lx: 0, ly: 0, lz: 0 });console.log(restoreCamera(good));console.log(restoreCamera(null), restoreCamera('{'), restoreCamera('{}'));console.log(restoreCamera('{"px":0,"py":0,"pz":"300","lx":0,"ly":0,"lz":0}'));{ px: 0, py: 50, pz: 300, lx: 0, ly: 0, lz: 0 }undefined undefined undefinedundefinedCopying the object into a { [key: string]: unknown } with a spread is what lets the predicate loop over the keys: value is only known to be an object, which has no index signature, and the copy has one whose values are all unknown. Number.isFinite also rejects Infinity, which JSON.parse returns for a number too large for a double, such as 1e999 in a hand-edited localStorage value.
- Write
toHealthStatus(text: string): GovernanceHealthStatus, which maps'ok'to'healthy','critical'to'error'and anything unexpected to'unknown', and a table of predictions thattscchecks for completeness, without anifchain.
Solution
type GovernanceHealthStatus = 'error' | 'warning' | 'healthy' | 'unknown' | 'contradictory';
// The border: every string the server may send becomes one of the statuses the front end knowsfunction toHealthStatus(text: string): GovernanceHealthStatus { switch (text) { case 'healthy': case 'ok': return 'healthy'; case 'warning': return 'warning'; case 'error': case 'critical': return 'error'; case 'contradictory': return 'contradictory'; default: return 'unknown'; }}
// Inside, the union is true, and a Record keyed by it must list every statusconst predictions: Record<GovernanceHealthStatus, string> = { healthy: 'stable', warning: 'at risk', error: 'failing', contradictory: 'uncertain', unknown: 'uncertain',};
for (const text of ['ok', 'critical', 'warning', 'purple']) { const status = toHealthStatus(text); console.log(text.padEnd(9), status.padEnd(8), predictions[status]);}ok healthy stablecritical error failingwarning warning at riskpurple unknown uncertainThe strings of the server are handled in one function, whose return type is the union: inside the program, status can only be one of the five values, and predictions[status] needs no fallback. A Record<GovernanceHealthStatus, string> must have a property for every member of the union, so a sixth status added to the type makes the table an error until someone decides its prediction. Lesson 4 shows how Record is built.
Sources
Section titled “Sources”- TypeScript handbook — Narrowing, Everyday types: union types and type assertions, Utility types
- TypeScript 3.7 release notes — assertion functions, 2.7 — object literal inference, 6.0 —
--stableTypeOrdering - Microsoft — switch expression, type-testing and cast operators
- Java — sealed classes and interfaces, pattern matching for switch