cheat sheet
React with TypeScript
Type-safe React patterns — function components, prop types, children, event handlers, refs, generic components, polymorphic `as` props, and Zod-powered forms.
React with TypeScript — Components, hooks, generics, and forms
What it is
React with TypeScript is the combination of Meta's UI library and Microsoft's typed JavaScript superset — together they give you compile-time guarantees about the shape of props, the type of hook state, the events your handlers receive, and the elements your refs point at. Modern React (18+) is "function components plus hooks", and the modern TS-React idiom is to annotate props with a plain type alias and let TypeScript infer everything else — no React.FC<>, no JSX.Element return annotation, no any. Alternatives in the same niche include Vue + Volar, Solid + TS, and Svelte 5 + TS, but the React + TS ecosystem (with libraries like react-hook-form, tanstack-query, zod, and radix-ui) is the broadest.
This article assumes the React fundamentals (JSX, hooks, components) covered in react-basics and focuses on the TS-specific patterns — typing props, event handlers, refs, and building reusable generic and polymorphic components.
Install
A new Vite + React + TypeScript project ships with @types/react and @types/react-dom already configured. For an existing JS-React project, install the same @types/* packages.
# New project (Vite + React + TS)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
# Add TS types to an existing React project
npm install -D typescript @types/react @types/react-dom
Output: (none — exits 0 on success)
The Vite template ships a tsconfig.json with "jsx": "react-jsx" (the modern automatic runtime — no import React from "react" at the top of every file).
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true
}
}
Output: (none — exits 0 on success)
Why not React.FC<Props>?
Older TS-React tutorials used const Component: React.FC<Props> = (props) => …. The current React team docs and most style guides recommend dropping FC because: (1) it implicitly added a children prop that you may not want, (2) it broke defaultProps inference, and (3) it makes generic components much harder to write. The current idiom is to type the arguments instead.
// Old style (avoid)
import type { FC } from "react";
const Hello: FC<{ name: string }> = ({ name }) => <h1>Hello, {name}</h1>;
// Modern style (preferred)
type HelloProps = { name: string };
function Hello({ name }: HelloProps) {
return <h1>Hello, {name}</h1>;
}
Output: (none — exits 0 on success)
You also do not need to annotate the return type. TypeScript infers JSX.Element (or ReactNode for fragments) automatically. Add : ReactNode explicitly only when the function may return null, a string, or an array.
Typing props
Props are typed with a plain type alias or interface. Prefer type for one-off prop bags and interface for component libraries (interfaces participate in declaration merging — see declaration-merging). Mark optional props with ?, give defaults via destructuring, and prefer string-literal unions over boolean flags for related variants.
type ButtonProps = {
label: string;
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
disabled?: boolean;
onClick?: () => void;
};
export function Button({
label,
variant = "primary",
size = "md",
disabled = false,
onClick,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}
// Usage
<Button label="Save" variant="primary" size="lg" onClick={() => save()} />;
Output: (rendered)
<button class="btn btn-primary btn-lg">Save</button>
Discriminated-union props
When one prop's presence depends on another, model the choice as a discriminated union of prop shapes (see discriminated-unions). The compiler then enforces "either A and B, or C and D — never a mix".
type LinkButtonProps =
| { as: "link"; href: string; onClick?: never }
| { as: "button"; href?: never; onClick: () => void };
export function LinkButton(props: LinkButtonProps) {
if (props.as === "link") {
return <a href={props.href}>open</a>;
}
return <button onClick={props.onClick}>open</button>;
}
<LinkButton as="link" href="/docs" />; // OK
<LinkButton as="button" onClick={() => {}} />; // OK
<LinkButton as="link" onClick={() => {}} />; // Error: onClick: never
Output: (none — exits 0 on success)
Children: ReactNode and friends
The children prop is anything renderable — text, numbers, JSX elements, arrays, false/null/undefined. The right type 99% of the time is ReactNode. Reserve ReactElement for "exactly one JSX element" and JSX.Element for legacy compatibility. There is a helper PropsWithChildren<T> that adds children: ReactNode to your props bag, but writing it out is usually clearer.
import type { ReactNode, ReactElement, PropsWithChildren } from "react";
// Explicit (clearer)
type CardProps = { title: string; children: ReactNode };
function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2>{title}</h2>
<div>{children}</div>
</section>
);
}
// With the helper (equivalent)
type CardProps2 = PropsWithChildren<{ title: string }>;
function Card2({ title, children }: CardProps2) {
return <section><h2>{title}</h2><div>{children}</div></section>;
}
// Exactly one child element
type WithIconProps = { icon: ReactElement; children: ReactNode };
function WithIcon({ icon, children }: WithIconProps) {
return <span>{icon}{children}</span>;
}
Output: (none — exits 0 on success)
Render-prop and function-as-children
A render prop is just a prop typed as a function returning ReactNode. Use this pattern for "I provide state, you decide how to render it" components.
type DropdownProps = {
options: string[];
children: (state: { open: boolean; toggle: () => void }) => ReactNode;
};
function Dropdown({ options, children }: DropdownProps) {
const [open, setOpen] = useState(false);
return (
<div className="dropdown">
{children({ open, toggle: () => setOpen((o) => !o) })}
{open && <ul>{options.map((o) => <li key={o}>{o}</li>)}</ul>}
</div>
);
}
// Caller controls the trigger UI
<Dropdown options={["one", "two"]}>
{({ open, toggle }) => (
<button onClick={toggle}>{open ? "Close" : "Open"}</button>
)}
</Dropdown>;
Output: (none — exits 0 on success)
Event handlers
Every DOM event in React is a generic synthetic event — typed as React.SomethingEvent<TElement>. The two patterns you will use 95% of the time are the inline arrow function (where TS infers the event type from the JSX prop) and the named handler (where you must spell the type out).
import type { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from "react";
function Form() {
// Inline — TS infers e as ChangeEvent<HTMLInputElement>
const [name, setName] = useState("");
// Named handler — must declare the type
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log("submit", name);
};
const handleKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") setName("");
};
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
if (e.shiftKey) console.log("shift-click");
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKey}
/>
<button onClick={handleClick}>send</button>
</form>
);
}
Output: (none — exits 0 on success)
Event-handler types vs. event types
React exports two related families: the event types (MouseEvent<T>, ChangeEvent<T>) and the handler types (MouseEventHandler<T>, ChangeEventHandler<T>). Use the handler form when typing a prop; use the event form when typing the parameter of a function.
import type { MouseEventHandler, ChangeEventHandler } from "react";
type IconButtonProps = {
icon: ReactNode;
onClick: MouseEventHandler<HTMLButtonElement>; // prop = handler
};
function onChangeAge(e: ChangeEvent<HTMLInputElement>) { // arg = event
console.log(e.target.value);
}
Output: (none — exits 0 on success)
Common event-target table
| JSX attribute | Event type | Notes |
|---|---|---|
onClick | MouseEvent<T> | T = target element (e.g. HTMLButtonElement) |
onChange (input/textarea) | ChangeEvent<HTMLInputElement> | e.target.value is string |
onChange (select) | ChangeEvent<HTMLSelectElement> | |
onSubmit | FormEvent<HTMLFormElement> | Always call e.preventDefault() |
onKeyDown / onKeyUp | KeyboardEvent<T> | e.key, e.code, e.shiftKey |
onFocus / onBlur | FocusEvent<T> | |
onDragStart | DragEvent<T> | e.dataTransfer |
onWheel | WheelEvent<T> | e.deltaY |
Typing useState
useState's type is usually inferred from the initial value — useState(0) is number, useState("hi") is string. When the type cannot be inferred (initial is null, undefined, or []), pass the type explicitly with useState<T>(...).
import { useState } from "react";
// Inferred
const [count, setCount] = useState(0); // number
const [name, setName] = useState("Alice Dev"); // string
// Explicit — initial value is null
const [user, setUser] = useState<User | null>(null);
// Explicit — initial value is an empty array
const [todos, setTodos] = useState<string[]>([]);
// Discriminated state machine
type Status =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ok"; data: User }
| { kind: "err"; message: string };
const [status, setStatus] = useState<Status>({ kind: "idle" });
Output: (none — exits 0 on success)
A discriminated-union state type prevents impossible combinations like { loading: true, data: someUser } — see discriminated-unions.
Typing useRef
useRef has two distinct shapes depending on what you pass. Get this wrong and TypeScript will reject your JSX ref={...} attribute.
| Initial value | Type | Use case |
|---|---|---|
useRef<HTMLInputElement>(null) | RefObject<HTMLInputElement> (read-only, attaches to JSX) | DOM element refs |
useRef<number>(0) | MutableRefObject<number> (writable) | Mutable values across renders |
useRef<number | null>(null) | MutableRefObject<number | null> | Mutable, starts unset |
import { useRef, useEffect } from "react";
// DOM ref — null start, RefObject shape
function AutoFocusInput() {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
return <input ref={ref} />;
}
// Mutable value ref — writable, MutableRefObject shape
function Counter() {
const renders = useRef(0);
renders.current++; // OK — writable
return <p>Rendered {renders.current} times</p>;
}
// Mutable + nullable — for timer IDs
function Tick() {
const id = useRef<ReturnType<typeof setInterval> | null>(null);
const start = () => {
id.current = setInterval(() => console.log("tick"), 1000);
};
const stop = () => {
if (id.current) clearInterval(id.current);
id.current = null;
};
return <><button onClick={start}>start</button><button onClick={stop}>stop</button></>;
}
Output: (none — exits 0 on success)
Forwarding refs
A wrapper component that exposes a child element's ref must use forwardRef. With TS, forwardRef<RefType, PropsType> declares both — the element being ref-attached and the component's own props.
import { forwardRef } from "react";
import type { ComponentPropsWithoutRef } from "react";
type InputProps = ComponentPropsWithoutRef<"input"> & {
label: string;
};
export const LabeledInput = forwardRef<HTMLInputElement, InputProps>(
({ label, ...rest }, ref) => (
<label>
<span>{label}</span>
<input ref={ref} {...rest} />
</label>
),
);
LabeledInput.displayName = "LabeledInput";
// Usage — ref is now typed as RefObject<HTMLInputElement>
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <LabeledInput ref={inputRef} label="Name" placeholder="Alice Dev" />;
}
Output: (none — exits 0 on success)
React 19 makes
refa regular prop — you can dropforwardRefand just declareref?: Ref<HTMLInputElement>in your props. For codebases on 17–18, keepforwardRef.
Generic components
A component can accept its own type parameter — the same mechanism as a generic function (see generics). The classic example is a <List<T>> that takes an array of T and a render function that receives a T. With function components this works seamlessly; with forwardRef it requires a small cast.
type ListProps<T> = {
items: T[];
keyFn: (item: T) => string | number;
render: (item: T) => ReactNode;
};
export function List<T>({ items, keyFn, render }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyFn(item)}>{render(item)}</li>
))}
</ul>
);
}
// Usage — T is inferred from items
type User = { id: number; name: string };
const users: User[] = [
{ id: 1, name: "Alice Dev" },
{ id: 2, name: "Bob Coder" },
];
<List
items={users}
keyFn={(u) => u.id}
render={(u) => <strong>{u.name}</strong>} // u: User
/>;
Output: (rendered)
<ul>
<li><strong>Alice Dev</strong></li>
<li><strong>Bob Coder</strong></li>
</ul>
Generic select
A real-world <Select<T>> pairs a generic value type with a render-fn for each option's label and an onChange that returns the typed value — not just the string ID.
type SelectProps<T> = {
options: T[];
value: T;
labelFn: (opt: T) => string;
keyFn: (opt: T) => string;
onChange: (next: T) => void;
};
export function Select<T>({ options, value, labelFn, keyFn, onChange }: SelectProps<T>) {
const handle = (e: ChangeEvent<HTMLSelectElement>) => {
const next = options.find((o) => keyFn(o) === e.target.value);
if (next) onChange(next);
};
return (
<select value={keyFn(value)} onChange={handle}>
{options.map((o) => (
<option key={keyFn(o)} value={keyFn(o)}>{labelFn(o)}</option>
))}
</select>
);
}
type Role = { id: string; label: string; level: number };
const roles: Role[] = [
{ id: "admin", label: "Admin", level: 3 },
{ id: "user", label: "User", level: 1 },
];
function RolePicker() {
const [role, setRole] = useState<Role>(roles[0]);
return (
<Select
options={roles}
value={role}
keyFn={(r) => r.id}
labelFn={(r) => `${r.label} (level ${r.level})`}
onChange={setRole} // setRole receives a Role, not a string
/>
);
}
Output: (rendered)
<select>
<option value="admin">Admin (level 3)</option>
<option value="user">User (level 1)</option>
</select>
Polymorphic as prop
A polymorphic component renders any HTML element (or another component) selected via an as prop, while keeping the right typed attributes for that element. <Box as="a" href="/x"> accepts href; <Box as="button" onClick={fn}> accepts onClick. This is the trick behind chakra-ui's Box and radix-ui's asChild cousins.
import type { ElementType, ComponentPropsWithoutRef } from "react";
type BoxOwnProps<E extends ElementType> = {
as?: E;
padding?: number;
};
type BoxProps<E extends ElementType> =
BoxOwnProps<E> & Omit<ComponentPropsWithoutRef<E>, keyof BoxOwnProps<E>>;
export function Box<E extends ElementType = "div">({
as,
padding = 0,
style,
...rest
}: BoxProps<E>) {
const Component = as ?? "div";
return <Component style={{ padding, ...style }} {...rest} />;
}
// All of these type-check correctly:
<Box padding={8}>plain div</Box>;
<Box as="a" href="/docs">link</Box>; // href is required for "a"
<Box as="button" onClick={() => alert("hi")}>btn</Box>;
<Box as="img" src="/x.png" alt="x" />;
Output: (none — exits 0 on success)
Polymorphic components quickly get harder when you add refs — see Matt Pocock's
react-polymorphic-typeswrite-up or thereact-ariasource. For most apps, a non-genericButton asprop covers the 80% use case.
Component prop helpers
React's type exports include several utilities for "give me the props of element X". Pick the right one for your wrapper or extender.
| Helper | What it returns |
|---|---|
ComponentProps<"button"> | All props of <button> (including ref) |
ComponentPropsWithoutRef<"button"> | Same, minus ref (most common for spread) |
ComponentPropsWithRef<"button"> | Same, with the right ref type |
ComponentProps<typeof Button> | The Props type of a custom component |
import type { ComponentPropsWithoutRef, ComponentProps } from "react";
// Extend a native element's props
type IconButtonProps = ComponentPropsWithoutRef<"button"> & {
icon: ReactNode;
};
function IconButton({ icon, children, ...rest }: IconButtonProps) {
return <button {...rest}>{icon}{children}</button>;
}
// Get the props of *your own* component (e.g. for a wrapper)
type WrappedButtonProps = ComponentProps<typeof IconButton>;
Output: (none — exits 0 on success)
Forms with Zod + React Hook Form
The endgame for type-safe forms in React is a single source of truth — a zod schema — that produces both runtime validation and the TS type of the form values. Pair it with react-hook-form's zodResolver and the input registrations are checked against the schema's keys at compile time.
npm install react-hook-form @hookform/resolvers zod
Output: (none — exits 0 on success)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// 1. Schema (source of truth)
const SignupSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 chars"),
age: z.coerce.number().int().min(13, "Must be 13+"),
role: z.enum(["user", "admin"]),
});
// 2. Inferred TS type
type SignupValues = z.infer<typeof SignupSchema>;
// 3. Component
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(SignupSchema),
});
const onSubmit = async (values: SignupValues) => {
// values is fully typed — { email: string; password: string; age: number; role: "user" | "admin" }
await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" placeholder="Email" {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" placeholder="Password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<input type="number" placeholder="Age" {...register("age")} />
{errors.age && <p>{errors.age.message}</p>}
<select {...register("role")}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button disabled={isSubmitting}>Sign up</button>
</form>
);
}
Output: (rendered, on submit with invalid email)
Invalid email
Try
register("emial")and TypeScript will flag the typo immediately —registeris constrained tokeyof SignupValues.
Context with TypeScript
createContext<T>(default) types the value held by a provider. The most common pitfall is forgetting to type the default and ending up with a null shape that every consumer has to narrow. Two clean patterns are: (1) a non-null default for primitive contexts, and (2) a tiny useXContext hook that throws when used outside a provider.
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
type Theme = "light" | "dark";
type ThemeCtx = { theme: Theme; toggle: () => void };
const ThemeContext = createContext<ThemeCtx | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const value: ThemeCtx = {
theme,
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
return ctx;
}
// Inside a component:
function Toolbar() {
const { theme, toggle } = useTheme(); // ctx is never null here
return <button onClick={toggle}>{theme}</button>;
}
Output: (none — exits 0 on success)
useReducer with discriminated actions
useReducer<Reducer<State, Action>> types both the state shape and the action union. Combined with a discriminated-union Action type, the reducer body gets full narrowing inside each case, and the dispatcher rejects malformed actions at the call site.
import { useReducer } from "react";
type State = { count: number; lastOp: string };
type Action =
| { type: "inc"; by: number }
| { type: "dec"; by: number }
| { type: "reset" }
| { type: "set"; value: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "inc": return { count: state.count + action.by, lastOp: `+${action.by}` };
case "dec": return { count: state.count - action.by, lastOp: `-${action.by}` };
case "reset": return { count: 0, lastOp: "reset" };
case "set": return { count: action.value, lastOp: `=${action.value}` };
default: {
const _exhaustive: never = action; // compile-time exhaustiveness
return state;
}
}
}
function Counter() {
const [s, dispatch] = useReducer(reducer, { count: 0, lastOp: "" });
return (
<>
<p>{s.count} ({s.lastOp})</p>
<button onClick={() => dispatch({ type: "inc", by: 1 })}>+1</button>
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
<button onClick={() => dispatch({ type: "set", value: 100 })}>set 100</button>
</>
);
}
Output: (rendered, after +1 +1 set-100)
100 (=100)
Custom hooks
Custom hooks are just functions whose name starts with use. Type their parameters and return value as you would any other function. If a hook returns a tuple, use as const (or annotate the return) so TS infers [T, (next: T) => void] rather than (T | (T => void))[].
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // [T, Dispatch<SetStateAction<T>>]
}
// Usage — TS knows count is number and setCount is (n: number) => void
const [count, setCount] = useLocalStorage("count", 0);
// And it works for any T
const [user, setUser] = useLocalStorage<{ name: string } | null>("user", null);
Output: (none — exits 0 on success)
Common pitfalls
React.FC<Props>— implicitchildren, no generic support. Usefunction Foo({...}: Props)instead.useState<T>()with no argument — givesT | undefined. Passnullor a real initial value:useState<User | null>(null).useRefmutability confusion —useRef<HTMLInputElement>(null)is read-only (RefObject);useRef(0)is writable (MutableRefObject). The compiler chooses based on the initial value.- Stringifying event values —
e.target.valueis alwaysstring. Coerce to number explicitly:Number(e.target.value)or usee.currentTarget.valueAsNumberfor<input type="number">. onChangevsonInput— React'sonChangefires on every keystroke (unlike DOM's onChange). Don't reach foronInput.as anyfor theasprop — defeats the purpose. Use the polymorphic recipe above withElementType+ComponentPropsWithoutRef.- Spreading
...restof unknown shape — type the rest explicitly withOmit<ComponentPropsWithoutRef<"button">, keyof MyProps>to avoidany. children: JSX.Element— too narrow; rejects strings, numbers, arrays,null. UseReactNode.- Mutating state directly —
state.count++does not re-render and TypeScript will not catch it unless you mark statereadonly. Always return a new object. - Forgetting
displayNameonforwardRef— React DevTools shows<ForwardRef>instead of your component name. SetComp.displayName = "Comp".
Real-world recipes
Typed wrapper around fetch
A common pattern is wrapping fetch with a Zod-validated response — runtime safety plus full inference on the consumer side.
import { z, ZodTypeAny } from "zod";
async function fetchJson<S extends ZodTypeAny>(
url: string,
schema: S,
init?: RequestInit,
): Promise<z.infer<S>> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return schema.parse(await res.json());
}
const User = z.object({ id: z.number(), name: z.string() });
// Usage — return type is inferred from the schema
const user = await fetchJson("/api/users/1", User); // { id: number; name: string }
Output: (none — exits 0 on success)
useAsync hook with discriminated state
A small async hook that returns a discriminated-union state — narrow on state.status in JSX for type-safe rendering.
import { useEffect, useState } from "react";
type Async<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "ok"; data: T }
| { status: "err"; error: Error };
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
const [state, setState] = useState<Async<T>>({ status: "idle" });
useEffect(() => {
let alive = true;
setState({ status: "loading" });
fn().then(
(data) => alive && setState({ status: "ok", data }),
(error) => alive && setState({ status: "err", error: error as Error }),
);
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
// Consumer
function Profile({ id }: { id: number }) {
const s = useAsync(() => fetchUser(id), [id]);
if (s.status === "loading") return <p>loading…</p>;
if (s.status === "err") return <p>error: {s.error.message}</p>;
if (s.status === "ok") return <p>hello {s.data.name}</p>;
return null; // idle
}
Output: (rendered)
hello Alice Dev
Polymorphic <Heading> for design systems
A common design-system pattern: render <h1>–<h6> based on a level prop while keeping all native heading attributes.
import type { ComponentPropsWithoutRef } from "react";
type Level = 1 | 2 | 3 | 4 | 5 | 6;
type HeadingProps = ComponentPropsWithoutRef<"h1"> & { level?: Level };
export function Heading({ level = 1, ...rest }: HeadingProps) {
const Tag = `h${level}` as const;
return <Tag {...rest} />;
}
<Heading level={2} className="hero">Welcome, Alice Dev</Heading>;
Output: (rendered)
<h2 class="hero">Welcome, Alice Dev</h2>
Typed cva (class-variance-authority) variants
The cva library lets you describe a component's class variants as data; TS then enforces only-valid variant combinations at the call site.
npm install class-variance-authority
Output: (none — exits 0 on success)
import { cva, type VariantProps } from "class-variance-authority";
const button = cva("btn", {
variants: {
intent: { primary: "btn-primary", danger: "btn-danger", ghost: "btn-ghost" },
size: { sm: "btn-sm", md: "btn-md", lg: "btn-lg" },
},
defaultVariants: { intent: "primary", size: "md" },
});
type ButtonProps = VariantProps<typeof button> &
ComponentPropsWithoutRef<"button">;
export function Button({ intent, size, className, ...rest }: ButtonProps) {
return <button className={button({ intent, size, className })} {...rest} />;
}
// All variants are autocompleted and type-checked
<Button intent="danger" size="lg">delete</Button>;
<Button intent="primary" size="xl">…</Button>; // Error: "xl" is not in size
Output: (none — exits 0 on success)
Discriminated-union event handlers in a <Select>
Pair the generic-<Select<T>> recipe with a discriminated-union of options so each variant gets the right typed metadata on selection.
type Option =
| { kind: "user"; id: string; name: string }
| { kind: "group"; id: string; members: number };
const options: Option[] = [
{ kind: "user", id: "u1", name: "Alice Dev" },
{ kind: "group", id: "g1", members: 12 },
];
function Picker() {
const [value, setValue] = useState<Option>(options[0]);
return (
<>
<Select
options={options}
value={value}
keyFn={(o) => o.id}
labelFn={(o) => o.kind === "user" ? o.name : `Group of ${o.members}`}
onChange={setValue}
/>
{value.kind === "user"
? <p>Selected user: {value.name}</p>
: <p>Selected group of {value.members}</p>}
</>
);
}
Output: (rendered, after selecting the group)
Selected group of 12
The value.kind === "user" check narrows value to the user variant — TypeScript knows value.name exists; in the else branch it knows value.members exists. No casts required.