TypeScript 02 · TypeScript basics
Types that change (unions and narrowing)
Union types, narrowing with conditions, literal types, discriminated unions with a completeness check and unknown for data from outside.
Loading lesson…
TypeScript 02 · TypeScript basics
Union types, narrowing with conditions, literal types, discriminated unions with a completeness check and unknown for data from outside.
Loading lesson…
A courier brings you a parcel and the label says: "a book or a mug". Until you open it, you can't leaf through it or drink from it – you don't know what's inside. Once you open it and look inside, you know for sure, and from then on you treat it according to what's in it.
That's exactly how TypeScript works with values that can be more than one thing. The type string | number is the label "text or a number". Until you look (with a condition), it only lets you do what works for both options. After the check it knows what's in the parcel and lets you use the methods that belong to its contents.
Lesson TypeScript 01 was about where types are written. This lesson is about types that describe several options – and about how TypeScript works out from your code which of them applies right now. In React you'll meet them at every step: a user or nothing yet; a loading state; data from the server you don't trust yet.
A vertical bar between types means "or". Such a type is called a union:
let id: string | number // text or a numberid = 42 // ✅id = 'A-42' // ✅id = true // ❌ boolean isn't on the labellet selected: User | null = null // a user, or nothing yet
You already know a union with null: useState<User | null>(null) from the previous lesson. Now the main point: with a value of a union type you may do only what works for every option. TypeScript doesn't know what's in the parcel right now, so it guards against the worst case.
function show(id: string | number) {id.toString() // ✅ both text and numbers have toStringid.toUpperCase() // ❌ Property 'toUpperCase' does not exist on type 'number'}
The error doesn't say "this is wrong" but "this might not work": if a number came in, toUpperCase wouldn't exist and the app would crash. The solution is to open the parcel first – that's the next section.
When you write a condition in your code that checks the type, TypeScript reads it and inside the branch treats the value as a narrower type. This is called narrowing. You don't have to declare anything extra – ordinary JavaScript is enough:
function greet(name: string | null) {if (name === null) return 'Hello!'// null can't get here – the function would have ended a line abovereturn `Hello, ${name.toUpperCase()}!` // ✅ name is a string here}
TypeScript understands many checks. These are the ones you'll use most:
| Check | For | Example |
|---|---|---|
typeof x === 'string' | text, number, boolean, function | if (typeof id === 'number') id.toFixed(2) |
x === null / x !== null | telling "nothing" apart from a value | if (user !== null) user.name |
Array.isArray(x) | an array or a single value | const list = Array.isArray(x) ? x : [x] |
'key' in x | an object with this property | if ('email' in contact) contact.email |
x instanceof Error | objects made from classes (Error, Date) | if (e instanceof Error) e.message |
x.status === 'error' | one of several object shapes (section 4) | if (state.status === 'error') state.message |
It works through return too: whatever has already "left" the function disappears from the type. So it pays to deal with the edge cases first (null, an error) and write the rest of the function for the normal one.
What you see: A short function that gets text, a number or null. Every line is a button; below the code you can see which type value has on that line.
Try it:
return 'nothing': the type narrows even though there's no condition there.return only number is left – that's why toFixed is allowed there.The takeaway: A value's type changes during the function according to what you already know. Every condition and every return removes from the union the options that can no longer happen.
Loading the interactive part…
A type doesn't have to be just string or number. It can also be one specific value – and a union is made of several of those:
type Theme = 'light' | 'dark'type Size = 'sm' | 'md' | 'lg'function setTheme(theme: Theme) { … }setTheme('dark') // ✅setTheme('Dark') // ❌ TypeScript spots the typo straight awaysetTheme('blue') // ❌ no such theme
It's like a form with tick boxes instead of an empty line: whoever fills it in can't write nonsense. The editor also offers the allowed values as soon as you type a quote. For component props it's priceless: variant: 'primary' | 'secondary' tells everyone who uses the component what they may pass.
Loading data has several states: nothing yet, loading, done, error. It's tempting to describe them with a few properties side by side – and that's a trap:
// ❌ don'tinterface State {isLoading: booleanerror?: stringuser?: User}// TypeScript happily allows nonsense:const state: State = { isLoading: true, error: 'Network is down', user: anna }// Is it loading? Is there an error? Do we have a user? All at once?
It's better to describe each state separately and join them into a union. All the shapes share a property with a literal type (here status) by which they can be told apart. It's called the discriminant, and the whole type a discriminated union:
// ✅ dotype State =| { status: 'idle' }| { status: 'loading' }| { status: 'success'; user: User }| { status: 'error'; message: string }
Only the 'success' state has a user, only 'error' has an error message. A nonsense combination can't even be written. And when you ask about status, TypeScript narrows the whole object:
function render(state: State) {switch (state.status) {case 'idle':return 'Click Load'case 'loading':return 'Loading…'case 'success':return state.user.name // ✅ user exists herecase 'error':return state.message // ✅ message exists here}}
What if in a month someone adds the state { status: 'cancelled' }? The function above silently skips it. There's a simple trick against that: after the switch add a line the code only reaches when some state is missing.
switch (state.status) {// … all the cases}const unreachable: never = state // ❌ an error as soon as a state is missingreturn unreachable
never is the type "this can't happen". When the switch handles every state, really nothing is left after it and the assignment passes. If a state is missing, it stays in state and TypeScript reports an error exactly on this line – before a user runs into the forgotten state.
What you see: Three buttons load a user from the fake API. Below them is what the component renders, and below that the current state object.
Try it:
status, then user too.message, but no user.StateView function. In each case it touches only the properties that state has.The takeaway: The state is always just one of the union's shapes. The component decides by status what to show, and TypeScript makes sure it doesn't touch data that isn't in that state.
Loading the interactive part…
Some values come from somewhere TypeScript can't see: from JSON, from the server, from localStorage, from a catch block. They're parcels without a label. TypeScript has two types for them and they're as different as night and day:
| any | unknown | |
|---|---|---|
| What it means | "don't check me" | "I don't know what this is" |
| What you may do with the value | anything – even nonsense | nothing until you check it |
| When an error shows up | only at run time, for the user | straight away in the editor |
| Where you meet it | JSON.parse, response.json() – they return any | catch (e), values you label yourself |
any switches checking off and spreads: whatever comes from it is any again. unknown is its safe sibling – it allows nothing until you narrow the value. So store the result of JSON.parse as unknown:
const data: unknown = JSON.parse(text)data.theme // ❌ 'data' is of type 'unknown'if (typeof data === 'object' && data !== null && 'theme' in data) {data.theme // ✅ the property exists (still check its value)}
Writing such a condition again and again would be tedious. Wrap it in a function with the return type x is User. Such a function is called a type guard: when it returns true, TypeScript knows the value is a User.
function isUser(x: unknown): x is User {return typeof x === 'object' && x !== null && 'name' in x && typeof x.name === 'string'}const data: unknown = await response.json()if (isUser(data)) {data.name.toUpperCase() // ✅ data is a User here}
An error in catch is handled the same way. Anything can be thrown (even text or a number), so the error has the type unknown:
try {await saveProfile()} catch (e) {const message = e instanceof Error ? e.message : 'Unknown error'}
Two functions that get a value of two possible types. First find out what came in, then return the right result.
Task is in the comments in the code below – at the top of the file and at the places marked TODO.
Loading the interactive part…
Safely reading settings stored as JSON: a type guard, a conversion that handles errors, and the error text from catch. What each function should do is in the comments in the code.
Task is in the comments in the code below – at the top of the file and at the places marked TODO.
Loading the interactive part…
Four loading states as a discriminated union: a text for each state with a completeness check, safely reading the name, and transitions between the states. What each function should do is in the comments in the code.
Medium and hard challenges unlock once you sign in. Like the whole course, they are free – just an e-mail, no password and no payment.
A | B means "A or B". Without a check you may do only what works for every option.typeof, === null, in, instanceof) and return, and inside a branch it knows a narrower type.'light' | 'dark') instead of any text – the editor spots typos. A union of strings does the job of an enum.status. A nonsense combination can't be written; never after a switch makes sure no state is missing.any. Check it with a type guard x is T; as checks nothing.