TypeScript for React
Add types to props, state, events and reducers so your editor catches mistakes before your users do.
Your Reading List has grown. There is a reducer, a form, a filter bar and a custom hook. Every one of those pieces passes book objects around and trusts that each one has a title, an author and a status. What happens if somebody writes book.titel, or dispatches { type: 'statusChange' } without the "d"? In plain JavaScript, nothing warns you. You find out when the page shows undefined or a button silently does nothing.
TypeScript is JavaScript plus types: little labels that describe what shape a value has. Your editor reads those labels and underlines mistakes while you type. React and TypeScript work very well together, and most professional React code today is written in TypeScript. From now on, every example in this course uses it.
Your first typed component
A TypeScript React file ends in .tsx instead of .jsx. The only new thing in the example below is the interface that describes the props BookCard accepts.
Read { title, author }: BookCardProps as "this function receives an object with a title and an author, and both are strings". The part after the colon is a type annotation. If you now write <BookCard title="Dune" /> and forget the author, your editor shows a red underline that says author is missing.
The sandboxes on this site strip types without checking them, so they will not show red underlines. Your code still runs, which is useful for learning, but the safety net only appears in a real editor like VS Code. When you convert your own project at the end of today, you will see the errors there.
Describing your data
Props are one place where types help. A bigger win is describing your data once and reusing that description everywhere. Here is the shape of a book in the Reading List, written as TypeScript.
export type Status = 'want' | 'reading' | 'done';
export interface Book {
id: string;
title: string;
author: string;
status: Status;
rating?: number;
}Three new ideas live in these few lines:
'want' | 'reading' | 'done'is a union type. The|means "or". AStatuscan only be one of those three exact strings, so'finished'is an error.type Status = ...gives that union a name, so you can reuse it.rating?: numberhas a question mark, which makes it an optional property. A book may or may not have a rating. When you readbook.rating, TypeScript knows it isnumber | undefined.
Now any component can import Book and say "I expect a book".
A few things to notice:
Book[]means "an array ofBook".import type { Book } from './types'imports only the type. It is erased when the code runs, which keeps your bundle clean.Record<Status, string>means "an object with one key for everyStatus, each holding a string". If you later add a fourth status, TypeScript points atstatusLabelsand tells you a label is missing.
Deep divetype or interface?
You can describe an object with either interface Book { ... } or type Book = { ... }. For props and data objects they behave almost the same, and teams pick one by convention. Unions like Status can only be written with type. This course uses interface for object shapes and type for everything else, which matches what you will see in most React codebases.
Typing state and events
Most of the time you do not need to write types for state at all. TypeScript infers (works out) the type from the initial value.
const [title, setTitle] = useState(''); // string
const [count, setCount] = useState(0); // number
const [books, setBooks] = useState(initialBooks); // Book[], because initialBooks is Book[]You only add a type when the initial value does not tell the whole story. A classic case is "nothing selected yet": the initial value is null, but later it will hold a book. You pass the type in angle brackets, which is called a generic argument.
const [selected, setSelected] = useState<Book | null>(null);Event handlers written inline in JSX are also inferred for you. When you pull a handler out into its own function, you name the event type yourself. React exports them, for example ChangeEvent for inputs.
Look at lastAdded && (...). Inside the parentheses TypeScript knows lastAdded is a Book, not null, because you checked it first. This is called narrowing, and you will use it constantly.
Typing a reducer
On Day 4 you wrote booksReducer with four actions: added, statusChanged, deleted and rated. This is where TypeScript shines. You describe every action as one member of a union, each with its own type string and its own extra fields.
export type Action =
| { type: 'added'; book: Book }
| { type: 'statusChanged'; id: string; status: Status }
| { type: 'deleted'; id: string }
| { type: 'rated'; id: string; rating: number };This pattern is called a discriminated union. The type field is the "discriminant": once you check action.type === 'deleted', TypeScript knows that this action has an id and nothing else. Inside each case of a switch you get exactly the right fields, and a typo like 'statusChange' is an error.
You never had to type books or dispatch in App. useReducer reads the types from booksReducer's signature, so dispatch only accepts an Action. In VS Code, typing dispatch({ type: ' pops up the four valid names.
Deep diveMaking the compiler check every case
If you add a fifth action to the union and forget to handle it, the default branch throws at runtime. You can make TypeScript catch it at compile time instead:
default: {
const unhandled: never = action;
throw new Error('Unknown action: ' + JSON.stringify(unhandled));
}never is the type of "a value that cannot exist". When every action is handled, nothing reaches default, so action there is never and the assignment is fine. When one is missing, action is that leftover action, which cannot be assigned to never, and you get an error that names it.
Rate a finished book
The reducer already handles rated, but nothing in the UI dispatches it. Add a row of buttons 1 to 5 next to each book whose status is 'done'. Clicking one dispatches a rated action, and the current rating is shown as (4/5). Books that are not done show no rating buttons.
Show solution
import { useReducer } from 'react';
import { booksReducer } from './booksReducer';
import { initialBooks } from './data';
const stars = [1, 2, 3, 4, 5];
export default function App() {
const [books, dispatch] = useReducer(booksReducer, initialBooks);
return (
<ul>
{books.map((book) => (
<li key={book.id}>
{book.title} ({book.status})
{book.status === 'done' && (
<>
{book.rating !== undefined && <span> ({book.rating}/5)</span>}{' '}
{stars.map((n) => (
<button key={n} onClick={() => dispatch({ type: 'rated', id: book.id, rating: n })}>
{n}
</button>
))}
</>
)}
</li>
))}
</ul>
);
}book.status === 'done' && ... decides whether the buttons appear. Because rating is optional, you check book.rating !== undefined before showing it. Try writing rating: '5' with quotes in VS Code: TypeScript rejects it, because the rated action says rating: number.
Given type Status = 'want' | 'reading' | 'done', which assignment does TypeScript reject?
Why does useState<Book | null>(null) need the type in angle brackets, while useState('') does not?
Day 5 progress