Lesson 13 / 23
Type Narrowing
Type narrowing through control flow analysis; the typeof, in, and instanceof guards, the trap of truthiness narrowing, type predicates, assertion functions, and situations where narrowing is lost.
Contents
In earlier lessons, a union type was tested several times and the compiler reduced it to a single member. The rules of this reduction have not yet been laid out.
Narrowing is the compiler shrinking a value’s type by looking at the tests in the control flow. The mechanism behind it is called control flow analysis: the compiler tracks, at every point in the program, which conditions have been proven. This lesson covers which tests count as proof, how your own validation is communicated to the compiler, and where the proof gets lost.
Built-in Guards
A type guard is a test that narrows a value’s type. The most common one is the
typeof operator:
function format(value: string | number | null): string { if (value === null) { return "-"; } if (typeof value === "number") { return value.toFixed(1); } return value.trim(); } console.log(format(21.4), format(" s-01 "), format(null));
The output is 21.4 s-01 -.
Three branches are three narrowing steps. The value === null test narrows by
equality; after the first return, the remaining type is string | number. After the
typeof value === "number" test, the type in the body is number and toFixed can be
called. By the time the last line is reached, the only remaining possibility is
string; the trim call needs no further test.
The last step deserves attention: the compiler tracks early returns. A branch that
ends with a return statement eliminates that possibility from the code that follows.
The guard clause pattern introduced in the Programming Fundamentals course pays off at
the type level too.
Object types have two more guards. The in operator checks for field presence:
type NumericMeasurement = { id: string; value: number }; type TextMeasurement = { id: string; text: string }; function write(record: NumericMeasurement | TextMeasurement): string { if ("value" in record) { return record.value.toFixed(1); } return record.text.trim(); } console.log(write({ id: "s-01", value: 21.4 })); console.log(write({ id: "s-02", text: " broken " }));
Output:
21.4 broken
The instanceof operator distinguishes class instances:
class FileSource { constructor(readonly path: string) {} } class NetworkSource { constructor(readonly address: string) {} } function describe(source: FileSource | NetworkSource): string { if (source instanceof FileSource) { return `file: ${source.path}`; } return `network: ${source.address}`; } console.log(describe(new FileSource("/data/measurement.jsonl"))); console.log(describe(new NetworkSource("10.0.0.7:9000")));
Output:
file: /data/measurement.jsonl network: 10.0.0.7:9000
What these three have in common is that all of them are tests that actually run at run time. Narrowing is not an assumption of the type system; it is a test in the code reflected at the type level.
The Trap of Truthiness Narrowing
Checking a value’s truthiness also narrows, but it eliminates more than expected:
function label(value: number | undefined): string { if (!value) { return "no measurement"; } return value.toFixed(1); } console.log(label(21.4)); console.log(label(0)); console.log(label(undefined));
Output:
21.4 no measurement no measurement
This file passes the type check, but the second output is wrong: 0 is a valid
measurement. The list of falsy values established in the JavaScript Fundamentals
course comes into play here — 0, "", and NaN also count as falsy, and the
!value test eliminates them too.
The type system does not catch this mistake, because the narrowing was done correctly:
after the !value block, the type really is number. The bug is not in the type, it
is in the choice of test.
Rule: write an equality test for undefined and null. if (value === undefined)
or if (value == null) — the latter eliminates both the null and the undefined
case, and this is the one defensible use of loose equality.
Type Predicates
When validation is moved into a separate function, narrowing is lost: the compiler
does not know what a function returning boolean has proven. A type predicate
carries this information in the signature:
interface Measurement { id: string; value: number; unit: "C" | "Pa" | "%"; } function isMeasurement(value: unknown): value is Measurement { if (typeof value !== "object" || value === null) { return false; } const candidate = value as Record<string, unknown>; return ( typeof candidate.id === "string" && typeof candidate.value === "number" && (candidate.unit === "C" || candidate.unit === "Pa" || candidate.unit === "%") ); } function parse(raw: string): string { const parsed: unknown = JSON.parse(raw); if (!isMeasurement(parsed)) { return "invalid record"; } return `${parsed.id}=${parsed.value}${parsed.unit}`; } console.log(parse('{"id":"s-01","value":21.4,"unit":"C"}')); console.log(parse('{"id":"s-02","value":"22.1","unit":"C"}')); console.log(parse('{"id":"s-03","value":22.1,"unit":"K"}'));
Output:
s-01=21.4C invalid record invalid record
The return type is written not as boolean but as value is Measurement. This tells
the compiler: “when this function returns true, accept that its argument is
Measurement.” The result is that, inside parse, parsed stops being unknown and
becomes Measurement.
This is where the course’s central point comes together: validation is done at run time, and a type predicate lets that be recorded as proof. The model can now be safely built from external data.
The predicate has a cost. The compiler does not check that the body actually proves what it claims:
interface Measurement { id: string; value: number; } function isMeasurement(value: unknown): value is Measurement { return typeof value === "object"; } const candidate: unknown = null; if (isMeasurement(candidate)) { console.log(candidate.value.toFixed(1)); }
The file passes the type check; when run:
TypeError: Cannot read properties of null (reading 'value')
The body gives typeof null === "object" as true and produces a false proof. A type
predicate is a debt, much like as: whoever writes it is responsible for its
correctness. This is why predicate bodies are kept short and tested.
Assertion Functions
The predicate’s second form throws on a value that fails the test:
interface Measurement { id: string; value: number; } function assertIsMeasurement(value: unknown): asserts value is Measurement { if ( typeof value !== "object" || value === null || typeof (value as Record<string, unknown>).id !== "string" || typeof (value as Record<string, unknown>).value !== "number" ) { throw new TypeError("not a measurement record"); } } const parsed: unknown = JSON.parse('{"id":"s-01","value":21.4}'); assertIsMeasurement(parsed); console.log(parsed.id, parsed.value.toFixed(1));
The output is s-01 21.4.
The asserts value is Measurement declaration narrows the type in the case where the
call returns normally. No if block is needed; in all the code after the call,
parsed is a Measurement.
The choice between the two forms depends on whether invalid data is an expected situation. The same distinction was made in the Type Conversion lesson of the Programming Fundamentals course: invalidity in user input is ordinary and is handled with a predicate; invalidity in data the program produces itself is exceptional and an assertion function is appropriate.
There is one constraint: the name an assertion function is called through must be an
explicitly typed declaration. If the function is first assigned to an untyped constant
and called through that constant, the compiler gives the TS2775 diagnostic: every
name at the target of an assertion call must carry an explicit type annotation.
Where Narrowing Is Lost
Control flow analysis works as long as it can assume the value has not changed since the test. That assumption does not hold inside a closure:
interface Measurement { id: string; last?: number; } function write(record: Measurement): void { if (record.last !== undefined) { console.log(record.last.toFixed(1)); [1, 2].forEach(() => { console.log(record.last.toFixed(1)); }); } } write({ id: "s-01", last: 21.4 });
n6.ts(10,19): error TS18048: 'record.last' is possibly 'undefined'.
The eighth line is valid, the tenth is not. The difference is that it is not known
when the callback will run: forEach runs synchronously, but the compiler cannot
know that, and the record.last field could have been deleted in between. The same
problem is even more visible in timers and asynchronous code.
The fix is to capture the narrowed value in a local constant:
interface Measurement { id: string; last?: number; } function write(record: Measurement): void { const last = record.last; if (last !== undefined) { console.log(last.toFixed(1)); [1, 2].forEach(() => { console.log(last.toFixed(1)); }); } } write({ id: "s-01", last: 21.4 });
A name bound with const cannot change; the compiler can carry the narrowing into the
closure. The output is three lines of 21.4.
Summary
- Narrowing is control-flow tests reflected at the type level;
typeof,in,instanceof, and equality tests are built-in guards. - A truthiness test also eliminates valid values like
0and""; an equality test should be written fornullandundefined. - A type predicate (
value is T) communicates the result of validation moved into a separate function to the compiler; the body’s correctness is not checked — the predicate is a debt. - An assertion function (
asserts value is T) throws on an invalid value and narrows the type when it returns normally. - Narrowing does not carry into a closure; capturing the narrowed value in a local constant crosses that boundary.
Next Step
The various shapes of a measurement record have so far been written out by hand: a read-only version, an update version with optional fields, a summary version carrying only a few fields. Each is a transform of the original type, and they need to be updated together whenever the type changes. The next lesson builds mapped types, which compute these transforms at the type level.
To keep your progress and take notes, Log in
My notes
Log in to take notes.