You typed a response as a union. Then you read a field off it and the compiler stopped you.
Property 'a' does not exist on type 'A', 'B'The union is not wrong. The narrowing is missing. A union declares what a value could be. Discrimination decides what it is right now.
Multi-type entities show up every day. API responses adopt several shapes. Collections carry several types. Parsed user input collapses to different scalars. Unions describe all of them.
const idk = string | number | PotatoFor primitives TypeScript does the work alone. A plain if-else narrows the value and the inference follows.
if (typeof idk === 'string') {
// idk: String
} else {
// idk: number | Potato
}Objects are where the inference runs out. Take the steps in this order.
- By default, let TypeScript infer.
- For simple types, any if-else will do.
- For objects, discriminate on an identifier field.
- Without an identifier field, run a morphic check.
- As a last resort, write a type predicate.
Two coins carry the examples from here on.
An identifier field is the recommended way
The recommended way is a constant string-type field in the object. TypeScript anchors on that field and infers the type, the same way it does with primitives.
type VaporCoin = { type: 'vapor' }
type NeonCoin = { type: 'neon' }
const act = (coin: VaporCoin | NeonCoin) => {
switch (coin.type) {
case 'vapor': {
// coin: VaporCoin
}
case 'neon': {
// coin: NeonCoin
}
}
}A morphic check reads the shape
Sometimes no single field is reliable. Sometimes the shapes are not yours to decide, as with a third-party API. Then infer the type by running a morphic check. The check looks for a difference in shape.
type VaporCoin = { vapor: string }
type NeonCoin = { neon: string }
const act = (coin: VaporCoin | NeonCoin) => {
if ('vapor' in coin) {
// coin: VaporCoin
} else {
// coin: NeonCoin
}
}A type predicate is the last resort
Everything else failed. The objects carry no identifier field and they are morphally equal. Only their inner values differ. So check those values in a function and let the function coerce the type. TypeScript calls these type predicates.
type VaporCoin = { key: string }
type NeonCoin = { key: string }
const isVapor = (tbd: unknown): tbd is VaporCoin => {
return tbd.key === 'vapor'
}
const act = (coin: VaporCoin | NeonCoin) => {
if (isVapor(coin)) {
// coin: VaporCoin
} else {
// coin: NeonCoin
}
}The coercion here is imperative. Your function decides the type, not the inference. That is the price of step 5, and it is why step 5 comes last.
Take the first step that compiles. Never open with step 5.