A double eviction means two live vote counts in one episode, phone lines and app votes and
producer notes all landing in the same inbox at once. Nobody upstream cleans that data before it
hits your function. Some entries are a plain string. Some are objects. Some are null because a
feed cut mid-call. Plain JavaScript will let you write an if that checks for all of that and
just as happily let you forget one. TypeScript's whole trick this week is that it actually reads
your if statements and remembers what they proved.
Narrowing: proving to TS what your if already knows
A union type like string | { score: number } | null means a value could be any of those three
shapes, and until you check, TypeScript treats it that cautiously, refusing to let you call
.toUpperCase() or read .score on something that might not have either. Narrowing is what
happens the moment you write a check that only one branch of the union could pass. Once you are
inside that if, TypeScript has proven the value down to just that branch, and every method and
property on it becomes fair game again.
The ES6 you write
function reportScore(entry) {
if (typeof entry === 'string') {
return entry
}
if (entry && typeof entry === 'object') {
return entry.score
}
return 0
}The TS version
type ScoreEntry = string | { player: string; score: number } | null
function reportScore(entry: ScoreEntry): string | number {
if (typeof entry === 'string') {
return entry
}
if (entry && typeof entry === 'object') {
return entry.score
}
return 0
}What changed
Both versions run the same three checks. The JS version's checks are just habit, nothing enforces them and nothing stops entry.score from being read on a string by accident somewhere else in the file. In the TS version, each check is a proof: typeof entry === 'string' narrows entry to string inside that block, and entry && typeof entry === 'object' narrows it to the object branch, with null already ruled out by the truthiness check.
Read that TS version one line at a time. typeof entry === 'string' rules out the object and
null branches, so inside that block entry is just string, full autocomplete included. The
second check does two jobs in one line: entry && is truthiness narrowing, it removes null
(and would remove undefined too, if that were in the union), and typeof entry === 'object'
removes the already-handled string branch. What is left is the object shape, so entry.score
type-checks clean.
The in operator: narrowing by shape
typeof gets you 'string', 'number', 'object', 'boolean', and a handful of others, which
is plenty when your union mixes primitives with objects. It stops being useful the moment a union
has two different object shapes, because typeof reports 'object' for both and cannot tell them
apart. That is what the in operator is for: it checks whether a property name exists on a value
at runtime, and TypeScript reads that check as proof of which shape you are holding.
type CallIn = { voter: string; note: string }
type TextIn = { voter: string; keyword: string }
function isCallIn(entry: CallIn | TextIn): entry is CallIn {
return 'note' in entry
}That entry is CallIn after the parentheses is a type predicate, and it is the one piece of
syntax in this whole lesson that is genuinely new rather than a JS habit renamed. A normal function
returning boolean just tells the caller true or false. A function returning entry is CallIn
tells the compiler something stronger: everywhere this function is called as an if condition,
narrow the checked value down to CallIn on the true branch, and to whatever is left on the
false branch. You write the check once, 'note' in entry, and every caller gets the narrowing
for free, forever, instead of re-deriving it from a bare boolean each time.
Truthiness, equality, and picking the right check
You already had truthiness narrowing above, entry && ruling out null. Equality narrowing works
the same way with a literal instead of a boolean coercion: if (status === 'evicted') narrows
status to the literal type 'evicted' inside that block, the same trick the switch from week
three leaned on. None of these checks are new JavaScript. What changes is that TypeScript is
finally watching them happen and updating its own understanding of your variables as it reads down
the function, branch by branch.
That is the whole lesson. Now prove it in the comp below.