TypeScript, as much as you need
The notations you keep running into in React code
TypeScript is JavaScript where you also write down the shape of your data. The editor then complains before the browser does.
You know JavaScript – so surprisingly little is missing here. A handful of notations covers almost everything that shows up in React projects.
Types are not a second program. They are comments that get checked. Before anything runs they are stripped out – the browser executes plain JavaScript.
The basic form: value : type
let name: string = "Ada";
let age: number = 36;
let active: boolean = true;
let tags: string[] = ["react", "ts"]; // array of strings
// Most of the time you don't need this – TypeScript works it out:
let title = "Hello"; // automatically string
// title = 42; // error: a number does not fit here
// Functions: what goes in, what comes out
function double(n: number): number {
return n * 2;
}
const shorten = (text: string, max = 20): string =>
text.length > max ? text.slice(0, max) + "…" : text; Write types only at the edges: props, function parameters, data from an API. Inside, TypeScript figures it out itself (InferenceTypeScript works out the type by itself, without you writing it down.→). const x: number = 5 is pure noise.
Describing objects
type User = {
id: string;
name: string;
email?: string; // the ? means: may be missing
};
// Combining two types:
type Admin = User & { rights: string[] };
// interface can do almost the same:
interface Product {
id: string;
price: number;
} `type` or `interface`? For React props it barely matters. Rule of thumb: use type – it does everything interface does, plus more.
Union: “one of these”
A Union“One of these”: string | number means the value is either a string or a number.→ lists the allowed options. With fixed words it becomes a set of choices your editor autocompletes.
type Variant = "primary" | "ghost" | "danger";
type Id = string | number;
let v: Variant = "ghost"; // fine
// let w: Variant = "blue"; // error – not one of them Instead of three booleans (loading, hasError, done) that can contradict each other, describe the states individually. Then an impossible state cannot even be written down.
type State =
| { status: "loading" }
| { status: "error"; text: string }
| { status: "done"; data: string[] };
function show(s: State) {
if (s.status === "loading") return "Loading …";
if (s.status === "error") return s.text; // text definitely exists here
return s.data.length + " results"; // data definitely exists here
} The technical term is Discriminated unionSeveral variants sharing one identifying field – TypeScript uses it to tell which variant you currently have.For example { status: "loading" } or { status: "error", text: string }. Check status and TypeScript knows which fields exist.→. And the narrowing-down through the check is called NarrowingTypeScript narrows a type down when you check it: after if (typeof x === "number") it knows x is a number.→. You do not have to remember the names – the pattern, yes.
Ready-made helpers you will see a lot
type User = { id: string; name: string; email: string };
Partial<User> // every field optional -> for updates
Pick<User, "id" | "name"> // only these two fields
Omit<User, "email"> // everything except email -> very common
Record<string, number> // object: text key, number value
keyof User // "id" | "name" | "email" ▸ In more depth: generics, as const, satisfies and unknown optional
Generics – a placeholder for a type
A GenericA placeholder for a type that is only decided when it is used – the <T> in Array<T>.→ is a variable for types. You already know them from useState<number>(0). You rarely write your own, but you should be able to read them.
// T is only decided at the call site
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // T = number
first(["a", "b"]); // T = string
// With a constraint: T must have an id field
function byId<T extends { id: string }>(items: T[], id: string) {
return items.find((i) => i.id === id);
} as const – freezing values
const ROLES = ["admin", "user"];
// type: string[]
type Role = (typeof ROLES)[number];
// -> string (useless) const ROLES = ["admin", "user"] as const;
// type: readonly ["admin", "user"]
type Role = (typeof ROLES)[number];
// -> "admin" | "user" ✨ This is how you derive types from your data instead of maintaining both by hand.
satisfies – check without coarsening
type Theme = Record<string, string>;
const theme = {
bg: "#0A0A0A",
ink: "#FFFFFF",
} satisfies Theme;
theme.bg; // the editor knows bg and ink
// theme.foo; // error – does not exist
// With ": Theme" instead of "satisfies Theme", theme.foo would have slipped through. unknown instead of any
any switches off checking – everywhere that value flows afterwards. unknown says “I don't know yet” and forces you to look first.
try {
risky();
} catch (err) { // err is unknown, not Error!
const text = err instanceof Error ? err.message : "Unknown error";
console.error(text);
} TypeScript checks beforehand and is then thrown away. So data from an API is not automatically correct just because you called it User. To make that safe, validate at runtime – with Zod, for example.
Does it stick?
5 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
The component shows a loading state – currently wired up with any and producing no output. Make each state produce the right display.
- □
loadingshowsLoading … - □
errorshows the text fromtext - □
doneshowsN results(N = length ofdata) - □Bonus: replace
anywith a union of three variants
type State = any; // TODO: replace with a union of three variants export function Display({ s }: { s: State }) { // TODO: return the right output for each state return <p>???</p>; } export default function App() { return ( <div style={{ fontFamily: "system-ui", padding: 16 }}> <Display s={{ status: "loading" }} /> <Display s={{ status: "error", text: "Server gone" }} /> <Display s={{ status: "done", data: ["a", "b", "c"] }} /> </div> ); }