TypeScript 13 ๐ท Control Flow Analysis and Narrowing
TypeScript doesn’t just check types โ it tracks how types change as control flows through your code. That’s control flow analysis. When you write if (typeof x === 'string'), TypeScript knows that inside the block, x is a string. When you check if (user !== null), it knows user isn’t null afterward. This narrowing is what makes unions usable, optional properties ergonomic, and type guards powerful. Without it, every union would require manual assertions; with it, the compiler follows your logic and updates the type for you.
Key point: Narrowing is automatic. You write ordinary JavaScript conditions โ typeof, in, instanceof, ===, truthiness โ and TypeScript narrows the type inside the corresponding branch. The compiler tracks every assignment, every branch, every return. By the time you use a value, TypeScript knows the most specific type it could possibly have at that point. That’s the payoff of a full type system integrated with control flow.
What control flow analysis is
Control flow analysis is the compiler’s process of tracking a variable’s type as execution moves through the program. At every point, TypeScript knows the narrowest type the variable could have, given all the conditions that led there.
function format(value: string | number): string {
// here, value: string | number
if (typeof value === 'string') {
// here, value: string
return value.toUpperCase();
}
// here, value: number (string eliminated)
return value.toFixed(2);
}
The type of value changes as control flow moves:
| Location | Type |
|---|---|
| Start | string | number |
Inside if (typeof === 'string') | string |
After the if | number |
TypeScript tracks this automatically. No annotation, no assertion.
What the compiler tracks:
- Assignments โ each assignment narrows to the assigned type
- Conditions โ each branch narrows based on the check
- Returns โ early returns remove cases from later code
- Loops โ re-narrows each iteration
- Functions โ return types flow back to call sites
- Discriminants โ literal properties narrow whole unions
Why this matters: Unions would be nearly unusable without narrowing. A string | number would force manual assertion everywhere. Narrowing makes unions ergonomic โ you write a check, and the compiler understands.
Why narrowing is the key feature: TypeScript isn’t just a checker โ it’s an analyzer. It reasons about when a value has which type. That’s what lets you write
if (x !== null) x.foo()instead of(x as NonNull).foo(). The compiler derives the narrowed type from the runtime check, so the code is both safe and idiomatic JavaScript.
The narrowing toolkit
TypeScript narrows via a specific set of checks. Each has its own rules.
| Check | Narrows |
|---|---|
typeof x === 'string' | Primitive types |
x instanceof C | Class instances |
'prop' in x | Property presence |
x === 'literal' | Literal types |
x !== null | Null removal |
x != null | Null and undefined removal |
if (x) | Truthy values |
Array.isArray(x) | Array |
x.kind === 'a' | Discriminated union branch |
isString(x) | Custom predicate |
Each tool has a use case. Narrowing is often about picking the right one.
typeof narrowing
typeof narrows among the primitive types.
function format(value: string | number | boolean): string {
if (typeof value === 'string') return value.toUpperCase();
if (typeof value === 'number') return value.toFixed(2);
return value ? 'yes' : 'no'; // value is boolean here
}
typeof returns a string that maps to TypeScript’s primitive types:
typeof result | TypeScript type |
|---|---|
'string' | string |
'number' | number |
'boolean' | boolean |
'bigint' | bigint |
'symbol' | symbol |
'undefined' | undefined |
'object' | object | null |
'function' | Function |
typeof null is 'object' โ a well-known JavaScript gotcha. TypeScript accounts for this: typeof x === 'object' narrows to object | null, so you must handle null separately.
function process(value: string | object | null): void {
if (typeof value === 'object') {
// value is object | null โ not just object
if (value !== null) {
// value is object
Object.keys(value);
}
}
}
typeof and undefined:
function greet(name: string | undefined): string {
if (typeof name === 'undefined') return 'Hello, stranger';
return `Hello, ${name}`; // name is string
}
Both typeof x === 'undefined' and x === undefined narrow. typeof is a safer check because it works even if the variable isn’t declared (though under strict, undeclared variables are errors).
typeof doesn’t narrow custom types:
interface User { name: string; }
function f(x: User | string): void {
if (typeof x === 'string') {
// x is string
} else {
// x is User
}
}
typeof only distinguishes primitives. An interface like User returns 'object', which narrows to object โ not to User. For custom types, use in, instanceof, or predicates.
Why
typeofnarrows but not custom types: JavaScript’stypeofonly returns a small set of strings โ it can’t distinguish between two object types. TypeScript follows JavaScript here:typeof x === 'object'narrows toobject, not to a specific interface. Custom narrowing needs a runtime check that distinguishes the types โ usuallyinor a predicate.
instanceof narrowing
instanceof narrows to a class instance.
class ApiError extends Error {
statusCode: number;
constructor(msg: string, code: number) {
super(msg);
this.statusCode = code;
}
}
function handle(error: Error | ApiError | string): string {
if (error instanceof ApiError) {
return `API error ${error.statusCode}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return error; // error is string
}
instanceof uses JavaScript’s prototype chain check โ it works for classes but not for interfaces or type aliases (which don’t exist at runtime).
Why interfaces can’t be used with instanceof:
interface User { name: string; }
const x: unknown = { name: 'Alice' };
x instanceof User; // โ User is a type, not a value
Interfaces are erased at compile time. instanceof needs a runtime value โ a constructor function.
Error handling is the classic use case:
try {
await fetchData();
} catch (err) {
// Under strict, err is unknown
if (err instanceof Error) {
console.error(err.message);
} else if (typeof err === 'string') {
console.error(err);
} else {
console.error('Unknown error');
}
}
The catch variable is unknown under strict (useUnknownInCatchVariables). instanceof Error is the standard way to narrow it.
instanceof only checks class hierarchies:
class Animal {}
class Dog extends Animal {}
const a: Animal = new Dog();
a instanceof Dog; // true โ Dog extends Animal
a instanceof Animal; // true
For unrelated classes, instanceof distinguishes them.
Why
instanceofbeatstypeoffor classes:typeofgives'object'for every class instance โ it can’t tell aDogfrom anAnimal.instanceofwalks the prototype chain and identifies the exact class. Usetypeoffor primitives,instanceoffor class instances, and predicates for interfaces.
in narrowing
The in operator narrows by checking whether a property exists.
interface Dog {
bark: () => void;
name: string;
}
interface Cat {
meow: () => void;
name: string;
}
function speak(pet: Dog | Cat): void {
if ('bark' in pet) {
pet.bark(); // pet is Dog
} else {
pet.meow(); // pet is Cat
}
}
'bark' in pet tells TypeScript that pet has a bark property โ which only Dog has.
in narrows to types that have the property:
type A = { a: number };
type B = { b: string };
type C = { a: number; b: string };
function f(x: A | B | C): void {
if ('a' in x) {
// x is A | C โ only these have a
}
if ('b' in x) {
// x is B | C โ only these have b
}
}
Multiple types may have the property โ in narrows to the union of those.
in with optional properties:
interface User {
id: number;
nickname?: string;
}
function greet(u: User): string {
if ('nickname' in u) {
// Without exactOptionalPropertyTypes: nickname is string
// With exactOptionalPropertyTypes: nickname is string | undefined
return `Hi, ${u.nickname ?? 'user'}`;
}
return `Hi, user #${u.id}`;
}
in checks presence. An optional property that’s present with undefined still passes the check, so you may need ?? anyway.
in doesn’t run the property’s value:
if ('bark' in pet) {
pet.bark(); // โ
bark is a function
}
The check is on the key’s existence, not its type. If bark were a number, TypeScript would still narrow but call pet.bark() would fail โ the narrowed type would say bark exists, but its type is whatever the union said.
When in is the tool: Discriminating between object shapes that have distinct properties. Common in discriminated unions, message handling, and event dispatch.
Why
inmatters for structural types: Interfaces are structural, not nominal โ two interfaces with different property names are distinguishable only by those names.inis the runtime check that reads those names. Combined with discriminated unions,inhandles most object-shape narrowing without needing classes.
Literal and discriminant narrowing
Comparing a value to a literal narrows the type to that literal.
type Status = 'idle' | 'loading' | 'ready';
function label(s: Status): string {
if (s === 'idle') {
// s is 'idle'
return 'Waiting';
}
if (s === 'loading') {
// s is 'loading'
return 'Loading...';
}
// s is 'ready'
return 'Ready';
}
Narrowing on literal unions works with ===, !==, switch, and || chains.
Discriminated unions extend this to objects:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'rect': return s.width * s.height;
}
}
Narrowing s.kind narrows the whole object. In the circle case, TypeScript knows s has radius. In square, it knows s has side. That’s the discriminated union โ narrowing one field narrows the entire shape.
Why this requires literal types: kind: string wouldn’t narrow. kind: 'circle' matches only one branch. That’s why literal types and narrowing are co-dependent.
Narrowing with === on literal objects:
type Command =
| { type: 'add'; value: number }
| { type: 'clear' };
function execute(c: Command): void {
if (c.type === 'add') {
console.log(c.value); // value is number
} else {
// c is { type: 'clear' }
}
}
Narrowing with !==:
if (s.kind !== 'circle') {
// s is 'square' | 'rect'
}
Negative narrowing works too.
Why literals and narrowing are a pair: A literal type has exactly one value. A check like
=== 'circle'either matches or doesn’t โ no ambiguity. That’s why discriminated unions narrow so cleanly: the discriminant is a literal, so===picks exactly one branch. Without literal types, no narrowing; without narrowing, no discriminated unions.
Truthiness narrowing
A truthy check narrows out falsy values.
function greet(name: string | null | undefined): string {
if (name) {
// name is string โ but also non-empty
return `Hello, ${name}`;
}
return 'Hello, stranger';
}
Truthiness removes: false, 0, '', null, undefined, NaN.
What truthiness narrows:
| Before | After if (x) |
|---|---|
string | null | string (non-empty) |
number | undefined | number (non-zero) |
User | null | User |
boolean | true |
string | string (non-empty โ but type stays string) |
Numbers and empty strings are risky:
function format(count: number | undefined): string {
if (count) {
// count is number โ but non-zero
return `${count} items`;
}
return 'none';
}
// format(0) โ 'none' โ but 0 is a valid count!
The truthy check catches 0. If 0 is valid, use !== undefined instead:
if (count !== undefined) {
return `${count} items`;
}
0 and '' are falsy but often valid. Truthiness is convenient for null/undefined but dangerous when 0 or '' are legitimate values.
Truthiness on objects:
interface User { name: string; }
function greet(user: User | null): string {
if (user) {
return `Hello, ${user.name}`; // user is User
}
return 'Hello, stranger';
}
Objects are always truthy, so the check narrows out null and undefined cleanly.
Truthiness with ??:
const display = name ?? 'Anonymous';
?? is not narrowing โ it’s an operator that provides a default. But it’s often what you want after narrowing decisions.
Why truthiness is both useful and dangerous: It’s the shortest way to check for
null/undefined. But it also eliminates0,'',false, andNaNโ values that are often valid. The rule: use truthiness when the falsy cases are genuinely “missing,” and explicit comparisons (!== undefined) when0or''are valid values.
Equality narrowing
=== and !== narrow based on the compared values.
Against null and undefined:
function f(x: string | null | undefined): string {
if (x == null) {
// x is null | undefined
return 'missing';
}
// x is string
return x;
}
x == null matches both null and undefined โ the only case where == is idiomatic. It narrows out both.
x === null narrows only null:
function g(x: string | null | undefined): string {
if (x === null) return 'null';
// x is string | undefined
if (x === undefined) return 'undefined';
return x; // string
}
Against literals:
if (status === 'ready') { /* status is 'ready' */ }
Between variables:
function f(a: string | number, b: string): void {
if (a === b) {
// a is string โ matches b's type
}
}
TypeScript narrows a to string because it equals a string.
switch with === semantics:
switch (status) {
case 'idle': return 0;
case 'ready': return 1;
// TypeScript narrows each case
}
switch uses === for case matching, so narrowing works the same as if chains.
Why
== nullis the one exception:== nullmatches bothnullandundefinedโ no other values. It’s a shorthand forx === null || x === undefined, and it’s safe because no other value is== null. Everywhere else, use===to avoid type coercion.
Array and in narrowing for objects
Array.isArray:
function firstOrLength(x: string | string[]): number | string {
if (Array.isArray(x)) {
// x is string[]
return x.length;
}
// x is string
return x;
}
Array.isArray narrows to array types.
in for property presence:
type Response =
| { data: string }
| { error: string };
function handle(r: Response): void {
if ('data' in r) {
console.log(r.data);
} else {
console.log(r.error);
}
}
Combining in and typeof:
type Payload =
| { kind: 'text'; text: string }
| { kind: 'count'; count: number };
function render(p: Payload): string {
if ('text' in p && typeof p.text === 'string') {
return p.text;
}
return String(p.count);
}
Multiple checks compound โ each narrows further.
in with optional properties and exactOptionalPropertyTypes:
interface User {
id: number;
nickname?: string;
}
// Without exactOptionalPropertyTypes
if ('nickname' in user) {
// user.nickname is string
}
// With exactOptionalPropertyTypes
if ('nickname' in user) {
// user.nickname is string | undefined
}
The strict flag changes what in proves.
Why
Array.isArrayandinare standard: They’re the JS-native checks that TypeScript recognizes.Array.isArraynarrows to array types (which are otherwise indistinguishable fromobject);indistinguishes object shapes by property name. Both are runtime checks โ they actually run โ so the narrowed types are verified, not just trusted.
Narrowing with never and exhaustiveness
When all cases of a union are handled, the remaining type becomes never.
type Status = 'idle' | 'loading' | 'ready' | 'error';
function assertNever(x: never): never {
throw new Error(`Unhandled: ${x}`);
}
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading...';
case 'ready': return 'Ready';
case 'error': return 'Failed';
default: return assertNever(s);
}
}
After all four cases, s is never โ the type with no values. If you add a new status and forget to handle it, s won’t be never in default, and assertNever(s) fails to compile.
Why never is a signal: never means “no possible value.” TypeScript uses it to prove exhaustiveness. If the remaining type after all cases is never, every case is covered. If it’s something else, a case is missing.
The default clause pattern:
default: {
const _exhaustive: never = s;
throw new Error('Unreachable');
}
Assigning s to a never variable is a common pattern. If s isn’t never, TypeScript errors.
When exhaustiveness matters:
- State machines โ every state must be handled
- Event handlers โ every event must have a case
- Reducers โ every action must be reduced
- Parsers โ every token type must be processed
Exhaustiveness is how you make the compiler enforce “handle all cases” as a rule, not a hope.
Why
neveris the empty type: In set theory, the empty set has no members.neveris TypeScript’s empty set โ no value belongs to it. When narrowing eliminates every case, the remaining type isnever. That’s not a bug โ it’s a proof that the code handles everything.assertNevermakes that proof explicit.
Narrowing across function boundaries
Narrowing happens within a single scope. It doesn’t cross function boundaries unless you use a predicate or a const variable.
Assignments inside a callback reset narrowing:
function process(user: User | null): void {
if (user) {
// user is User here
setTimeout(() => {
// โ user is User | null again โ the compiler can't know
// whether the callback runs before or after user changes
user.name;
}, 100);
}
}
The compiler can’t know when the callback runs, so it reverts to the declared type.
Const narrowing persists:
function process(user: User | null): void {
if (user) {
const u = user; // u is User โ a const binding
setTimeout(() => {
u.name; // โ
u is User โ captured as const
}, 100);
}
}
Assigning to a const captures the narrowed type. The compiler trusts the const never changes.
Type predicates cross boundaries:
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null && 'name' in x;
}
function process(x: unknown): void {
if (isUser(x)) {
setTimeout(() => {
x.name; // โ
x is User โ predicate narrowed it
}, 100);
}
}
Predicates assert the type from that point on, even inside callbacks.
Why this matters: Callbacks run later. The compiler can’t know whether a variable was reassigned between the check and the callback. Assign to a const or use a predicate to carry the narrowing forward.
Why const narrowing works: A
constcan’t be reassigned. If TypeScript narrows it toUserinside anif, it staysUserforever โ including inside a closure. This is why capturing narrowed values inconstis a common pattern. It’s the compiler’s proof that the value can’t change.
User-defined type predicates
A type predicate is a function whose return type is value is T. It narrows when the function returns true.
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function handle(value: unknown): void {
if (isString(value)) {
value.toUpperCase(); // value is string
}
}
The value is string annotation tells TypeScript: “If this returns true, value is a string.”
Predicates compose:
function isNonEmptyString(x: unknown): x is string {
return typeof x === 'string' && x.length > 0;
}
function isNumber(x: unknown): x is number {
return typeof x === 'number' && !Number.isNaN(x);
}
Predicates on unions:
type Result = { ok: true; value: string } | { ok: false; error: string };
function isSuccess(r: Result): r is { ok: true; value: string } {
return r.ok;
}
Predicates are trusted โ don’t lie:
function isUser(x: unknown): x is User {
return true; // โ lies โ compiles, breaks callers
}
The predicate is an assertion. TypeScript trusts you. If the function returns true for values that aren’t User, the narrowed types will be wrong.
When to use predicates:
- Validating external data (JSON, API responses, user input)
- Narrowing across function boundaries
- Reusable checks
- Complex conditions the compiler can’t track
Predicates vs assertions: Predicates are runtime-checked. as is not. Prefer predicates whenever a runtime check is possible.
Why predicates are the safest narrowing tool: They run at runtime. The compiler trusts the annotation, but the check itself happens for real. Unlike
as, which is pure trust, a predicate says “if this check passes at runtime, then the type is T.” That’s the strongest narrowing you can write, and it’s the right choice at every boundary.
asserts โ assertion functions
An assertion function narrows by throwing when a condition fails.
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== 'string') {
throw new Error('Not a string');
}
}
function handle(x: unknown): void {
assertIsString(x);
x.toUpperCase(); // x is string
}
After the call, x is narrowed. If the value wasn’t a string, the function threw โ so the code past the call is unreachable.
asserts condition without a type:
function assert(condition: unknown, msg?: string): asserts condition {
if (!condition) throw new Error(msg ?? 'Assertion failed');
}
function f(x: unknown): void {
assert(typeof x === 'string');
// x is narrowed to string from here
}
asserts condition narrows based on the condition expression passed in.
Assertion functions must be declared with function syntax:
// โ
works
function assertIsString(x: unknown): asserts x is string { ... }
// โ arrow function can't have an `asserts` return type
const assertIsString = (x: unknown): asserts x is string => { ... };
The arrow form fails because asserts requires an explicit return type annotation that arrow functions can’t provide in the same way.
asserts vs predicates:
| Feature | is T | asserts x is T |
|---|---|---|
| Form | if (fn(x)) | fn(x) โ throws |
| Control flow | Branches | Linear |
| Narrows on | true return | Successful return |
| Throws | No | Yes |
When to use asserts: When you want linear narrowing without if blocks. Common in validation utilities.
Why
assertsexists: Sometimes anifblock isn’t the right shape โ you want to validate and continue, not branch.assertsgives you that: call the function, and if it doesn’t throw, the value is narrowed. The compiler understands that the code after the call only runs when the assertion passed. This is the type-safe version of Node’sassertโ the same idea, checked at compile time.
A full example
A small message handler with narrowing across all the tools.
// ============================================
// TYPES
// ============================================
type Message =
| { kind: 'text'; text: string }
| { kind: 'image'; url: string; alt?: string }
| { kind: 'file'; name: string; size: number };
// ============================================
// PREDICATES
// ============================================
function isText(
m: Message
): m is { kind: 'text'; text: string } {
return m.kind === 'text';
}
function isImage(
m: Message
): m is { kind: 'image'; url: string; alt?: string } {
return m.kind === 'image';
}
// ============================================
// ASSERTION
// ============================================
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
// ============================================
// DISPATCH โ NARROWING
// ============================================
function render(m: Message): string {
switch (m.kind) {
case 'text':
return m.text;
case 'image':
return `[image: ${m.url}]${m.alt ? ` โ ${m.alt}` : ''}`;
case 'file':
return `[file: ${m.name} (${m.size} bytes)]`;
default:
return assertNever(m);
}
}
// ============================================
// GUARDS IN FUNCTIONS
// ============================================
function textOrNull(m: Message): string | null {
if (isText(m)) return m.text;
if (isImage(m)) return m.alt ?? null;
return null;
}
// ============================================
// ARRAY FILTERING WITH PREDICATES
// ============================================
const messages: Message[] = [
{ kind: 'text', text: 'hello' },
{ kind: 'image', url: '/a.png', alt: 'A' },
{ kind: 'file', name: 'doc.pdf', size: 1024 },
{ kind: 'text', text: 'world' }
];
const texts = messages.filter(isText);
// texts: { kind: 'text'; text: string }[]
// ============================================
// USAGE
// ============================================
for (const m of messages) {
console.log(render(m));
}
console.log(texts.map(t => t.text).join(', '));
Every narrowing tool is used: switch on a discriminant, if with a predicate, filtering with a predicate that narrows the array, exhaustive default with assertNever.
Why this pattern is idiomatic: Discriminated unions plus narrowing give you complete, exhaustive handlers. Adding a new message kind triggers compile errors in
render. TheisTextpredicate composes with.filter()to produce a typed subset. This is how real dispatch code is written โ safe, checked, exhaustive.
Complete Example Session
# ============================================
# PART 1: PRIMITIVE NARROWING
# ============================================
cat > primitives.ts << 'EOF'
function format(v: string | number | boolean): string {
if (typeof v === 'string') return v.toUpperCase();
if (typeof v === 'number') return v.toFixed(2);
return v ? 'yes' : 'no';
}
console.log(format('hi'), format(3.14), format(true));
EOF
npx tsc --noEmit primitives.ts
# (no errors)
# ============================================
# PART 2: INSTANCEOF
# ============================================
cat > instanceof.ts << 'EOF'
class HttpError extends Error {
constructor(msg: string, public status: number) { super(msg); }
}
function handle(err: unknown): string {
if (err instanceof HttpError) return `${err.status}: ${err.message}`;
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return 'Unknown error';
}
console.log(handle(new HttpError('Not found', 404)));
console.log(handle(new Error('Oops')));
console.log(handle('plain'));
EOF
npx tsc --noEmit instanceof.ts
# (no errors)
# ============================================
# PART 3: IN
# ============================================
cat > in.ts << 'EOF'
type Response = { data: string } | { error: string };
function handle(r: Response): string {
if ('data' in r) return r.data;
return r.error;
}
console.log(handle({ data: 'ok' }));
console.log(handle({ error: 'bad' }));
EOF
npx tsc --noEmit in.ts
# (no errors)
# ============================================
# PART 4: DISCRIMINATED UNION
# ============================================
cat > discriminated.ts << 'EOF'
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
}
}
console.log(area({ kind: 'circle', radius: 1 }));
console.log(area({ kind: 'square', side: 2 }));
EOF
npx tsc --noEmit discriminated.ts
# (no errors)
# ============================================
# PART 5: EXHAUSTIVENESS
# ============================================
cat > exhaustive.ts << 'EOF'
type Status = 'idle' | 'loading' | 'ready' | 'error';
function assertNever(x: never): never {
throw new Error(`Unhandled: ${x}`);
}
function message(s: Status): string {
switch (s) {
case 'idle': return 'Waiting';
case 'loading': return 'Loading';
case 'ready': return 'Ready';
case 'error': return 'Failed';
default: return assertNever(s);
}
}
console.log(message('idle'), message('ready'));
EOF
npx tsc --noEmit exhaustive.ts
# (no errors)
# ============================================
# PART 6: PREDICATES AND ASSERTIONS
# ============================================
cat > predicates.ts << 'EOF'
interface User { id: number; name: string; }
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& typeof (x as User).id === 'number'
&& typeof (x as User).name === 'string';
}
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('Not a user');
}
function handle(x: unknown): void {
assertUser(x);
console.log(x.name); // x is User
}
const maybe: unknown = { id: 1, name: 'Alice' };
if (isUser(maybe)) console.log('user:', maybe.name);
handle({ id: 2, name: 'Bob' });
EOF
npx tsc --noEmit predicates.ts
# (no errors)
# ============================================
# PART 7: COMPILE AND RUN
# ============================================
npx tsc primitives.ts instanceof.ts in.ts discriminated.ts exhaustive.ts predicates.ts
node primitives.js
# [ HI 3.14 yes ]
node instanceof.js
# [ 404: Not found ]
# [ Oops ]
# [ plain ]
node in.js
# [ ok ]
# [ bad ]
node discriminated.js
# [ 3.141592653589793 ]
# [ 4 ]
node exhaustive.js
# [ Waiting Ready ]
node predicates.js
# [ user: Alice ]
# [ Bob ]
Quick Reference
Narrowing Tools
| Check | Narrows |
|---|---|
typeof x === 'string' | Primitive |
x instanceof C | Class instance |
'p' in x | Object shape |
x === literal | Literal type |
x !== null | Removes null |
x != null | Removes null and undefined |
if (x) | Truthy |
Array.isArray(x) | Array |
x.kind === 'a' | Discriminated union |
isT(x) | Custom predicate |
assertIsT(x) | Assertion function |
typeof Results
| Result | Type |
|---|---|
'string' | string |
'number' | number |
'boolean' | boolean |
'bigint' | bigint |
'symbol' | symbol |
'undefined' | undefined |
'object' | object | null |
'function' | Function |
Truthiness Removes
| Value | Falsy? |
|---|---|
false | โ |
0 | โ |
-0 | โ |
0n | โ |
'' | โ |
null | โ |
undefined | โ |
NaN | โ |
{} | โ |
[] | โ |
'0' | โ |
Equality Narrowing
| Check | Narrows |
|---|---|
x === null | Only null |
x === undefined | Only undefined |
x == null | Both null and undefined |
x === 'a' | Literal 'a' |
x === other | Type of other |
in Narrowing Rules
| Situation | Result |
|---|---|
'p' in x where only A has p | x is A |
Multiple types have p | Union of those |
| Optional property | Present but maybe undefined |
With exactOptionalPropertyTypes | | undefined preserved |
Predicates vs Assertions
x is T | asserts x is T | |
|---|---|---|
| Usage | if (fn(x)) | fn(x) โ throws |
| Control flow | Branches | Linear |
| Runtime check | โ (yours) | โ (yours) |
| Narrows on | true return | Successful return |
never and Exhaustiveness
| After handling | Remaining type |
|---|---|
| All cases of union | never |
| Some cases | Remaining union |
default with never | Compile error if not exhaustive |
assertNever | Runtime throw if unreachable |
Narrowing Across Boundaries
| Case | Narrowing persists? |
|---|---|
| Same scope | โ |
| Inside callback | โ |
Captured in const | โ |
| Via predicate | โ |
After await | โ ๏ธ may reset |
| After assignment | โ reset |
Array.isArray
| Before | After |
|---|---|
unknown | unknown[] |
string | string[] | string[] |
object | any[] |
Best Practices
โ Do This:
// Narrow with typeof for primitives
if (typeof x === 'string') { x.toUpperCase(); } // โ
// Use instanceof for class errors
if (err instanceof Error) { err.message; } // โ
// Use in for object shapes
if ('data' in response) { response.data; } // โ
// Switch on discriminants for unions
switch (msg.kind) { case 'text': return msg.text; } // โ
// Exhaustive checks with assertNever
default: return assertNever(msg); // โ
// Use predicates at boundaries
if (isUser(data)) { data.name; } // โ
// Capture narrowing in const for callbacks
if (user) { const u = user; setTimeout(() => u.name); } // โ
// Use `== null` to check both null and undefined
if (x == null) return; // โ
// Prefer `!== undefined` when 0 or '' are valid
if (count !== undefined) { /* ... */ } // โ
// Use asserts for linear validation
assertIsString(input); // โ
โ Don’t Do This:
// Don't use truthiness for nullable numbers
if (count) { } // 0 skipped // โ ๏ธ use !== undefined
// Don't use truthiness for strings
if (name) { } // '' skipped // โ ๏ธ use !== undefined
// Don't rely on narrowing inside callbacks
if (user) { setTimeout(() => user.name); } // โ // โ capture as const
// Don't use `as` where narrowing works
const s = (x as string).toUpperCase(); // โ ๏ธ narrow instead
// Don't write predicates that lie
function isUser(x: unknown): x is User { return true; } // โ
// Don't forget to handle null with typeof object
if (typeof x === 'object') { /* x is object | null */ } // โ ๏ธ check null
// Don't skip exhaustiveness checks
default: break; // silently misses cases // โ ๏ธ assertNever
// Don't use `instanceof` on interfaces
x instanceof User; // User isn't a value // โ
// Don't compare with `==` except against null
if (x == 0) { } // coerces // โ ๏ธ use ===
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
Truthiness on 0 / '' | Treated as missing | Use !== undefined |
typeof x === 'object' | Includes null | Check x !== null |
| Narrowing in callbacks | Resets | Capture as const |
instanceof on interfaces | No runtime value | Use predicates |
| Missing exhaustiveness | Silent skip | assertNever default |
as instead of narrowing | Unsafe | Narrow with checks |
| Lying predicate | Runtime crashes | Write correct checks |
Forgetting default | Non-exhaustive | Add assertNever |
== except null | Type coercion | Use === |
Optional props with in | undefined possible | Use ?? or check |
Real-World Examples
1. Primitive narrowing
if (typeof x === 'string') { x.toUpperCase(); }
2. Number narrowing
if (typeof x === 'number') { x.toFixed(2); }
3. Error narrowing
if (err instanceof Error) { err.message; }
4. Custom error
if (err instanceof HttpError) { err.status; }
5. in narrowing
if ('data' in response) { response.data; }
6. Discriminated union
switch (msg.kind) { case 'text': return msg.text; }
7. Null check
if (user !== null) { user.name; }
8. Both null and undefined
if (x == null) return;
9. Truthiness for objects
if (user) { user.name; }
10. Non-zero number
if (count !== undefined) { /* 0 is valid */ }
11. Array narrowing
if (Array.isArray(x)) { x.length; }
12. Type predicate
function isUser(x: unknown): x is User { /* ... */ }
13. Assertion function
function assertUser(x: unknown): asserts x is User { }
14. Exhaustive switch
default: return assertNever(msg);
15. Filter with predicate
const texts = messages.filter(isText);
16. Capture narrowing in const
if (user) { const u = user; fn(() => u.name); }
17. Narrowing optional property
if (u.nickname !== undefined) { u.nickname.length; }
18. Switch on literal union
switch (status) { case 'ready': return true; }
19. Narrowing with ||
if (x === 'a' || x === 'b') { /* x is 'a' | 'b' */ }
20. Compound narrowing
if ('name' in obj && typeof obj.name === 'string') { }
Visual: Narrowing Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function format(v: string | number) { โ
โ โ
โ // v: string | number โ
โ โ
โ if (typeof v === 'string') { โ
โ // v: string โ
โ v.toUpperCase(); โ
โ } โ
โ โ
โ // v: number (string eliminated) โ
โ v.toFixed(2); โ
โ โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Discriminated Union Narrowing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Shape = โ
โ | { kind: 'circle'; radius: number } โ
โ | { kind: 'square'; side: number }; โ
โ โ
โ switch (s.kind) { โ
โ case 'circle': โ
โ // s: { kind: 'circle'; radius: number }โ
โ return Math.PI * s.radius ** 2; โ
โ โ
โ case 'square': โ
โ // s: { kind: 'square'; side: number } โ
โ return s.side ** 2; โ
โ } โ
โ โ
โ Narrowing one field narrows the whole union โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: typeof Results
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ typeof x === 'string' โ string โ
โ typeof x === 'number' โ number โ
โ typeof x === 'boolean' โ boolean โ
โ typeof x === 'bigint' โ bigint โ
โ typeof x === 'symbol' โ symbol โ
โ typeof x === 'undefined' โ undefined โ
โ typeof x === 'function' โ Function โ
โ typeof x === 'object' โ object | null โ
โ โ
โ โ ๏ธ typeof null === 'object' โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Narrowing Across Boundaries
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Same scope โ narrows โ
โ โ
โ if (user) { โ
โ user.name; โ
โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Inside callback โ resets โ
โ โ
โ if (user) { โ
โ setTimeout(() => { โ
โ user.name; โ user is User | null โ
โ }); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Captured as const โ persists โ
โ โ
โ if (user) { โ
โ const u = user; โ
โ setTimeout(() => { โ
โ u.name; โ
u is User โ
โ }); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Via predicate โ persists โ
โ โ
โ if (isUser(x)) { โ
โ setTimeout(() => { โ
โ x.name; โ
x is User โ
โ }); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: never and Exhaustiveness
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Status = 'a' | 'b' | 'c'; โ
โ โ
โ switch (s) { โ
โ case 'a': return 1; โ
โ case 'b': return 2; โ
โ case 'c': return 3; โ
โ default: โ
โ // s is never โ
โ return assertNever(s); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Add 'd' to Status: โ
โ โ
โ switch (s) { โ
โ case 'a': ... case 'b': ... case 'c': ... โ
โ default: โ
โ // s is 'd' โ not never โ
โ return assertNever(s); โ
โ // โ Argument of type 'string' โ
โ // not assignable to 'never' โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Predicate vs Assertion
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Predicate โ branching โ
โ โ
โ if (isUser(x)) { โ
โ x.name; // x is User โ
โ } else { โ
โ // x is not User โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Assertion โ linear โ
โ โ
โ assertUser(x); โ
โ x.name; // x is User (or throw earlier) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Truthiness Trap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function f(count: number | undefined) { โ
โ if (count) { โ
โ // runs only if count is non-zero โ
โ // skips 0 โ often a valid value โ
โ } โ
โ } โ
โ โ
โ f(0) โ falls through, treats 0 as missing โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function f(count: number | undefined) { โ
โ if (count !== undefined) { โ
โ // runs for 0 too โ
โ } โ
โ } โ
โ โ
โ f(0) โ enters block โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Narrowing Checklist
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Which tool? โ
โ โ
โ Primitive type? โ typeof โ
โ Class instance? โ instanceof โ
โ Object shape? โ in โ
โ Literal union? โ === โ
โ Discriminated union? โ switch on kind โ
โ Null/undefined? โ !== null / == null โ
โ Array? โ Array.isArray โ
โ Custom type? โ predicate โ
โ Post-check narrowing? โ asserts โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Concept | Meaning |
|---|---|
| Control flow analysis | Compiler tracks types as code runs |
| Narrowing | Type becomes more specific in a branch |
typeof | Narrows primitives |
instanceof | Narrows class instances |
in | Narrows object shapes |
=== | Narrows literals and discriminants |
| Truthiness | Narrows out falsy values |
!== null | Removes null |
== null | Removes null and undefined |
Array.isArray | Narrows to array |
| Predicate | x is T โ runtime check |
| Assertion function | asserts x is T โ throws |
never | Remaining type after exhaustive cases |
assertNever | Helper that proves exhaustiveness |
Key takeaways:
- Narrowing is automatic โ write ordinary JS conditions, and TypeScript updates the type
typeofnarrows primitives;instanceofnarrows classes;innarrows object shapes- Discriminated unions narrow via literal discriminants โ
switch (x.kind) - Truthiness removes all falsy values โ dangerous for
0and'' x == nullis the one idiomatic==โ matches bothnullandundefinedArray.isArraynarrows to array typesnevermeans exhaustive โ pairassertNeverwith adefaultbranch- Narrowing doesn’t cross callbacks โ capture in
constor use predicates - Type predicates (
x is T) are runtime-checked and safe - Assertion functions (
asserts x is T) narrow linearly by throwing - Prefer
!== undefinedover truthiness when0or''are valid - Prefer predicates over
asat boundaries โ they’re checked
Remember: TypeScript doesn’t just check types โ it tracks them through your code. Every if, switch, typeof, in, and predicate updates the compiler’s view of a value. That’s what makes unions ergonomic, optional fields safe, and exhaustive switches possible. Write ordinary JavaScript conditions; trust TypeScript to narrow. When narrowing can’t reach โ across callbacks, at API boundaries โ use predicates and assertion functions. The compiler does the rest.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!