TypeScript на frontend — это та же граница доверия, что типы в backend: внутри приложения данные имеют форму, и компилятор её держит. Но типизировать можно по-разному. any и as превращают TypeScript в декорацию; точные типы превращают его в инструмент, который ловит ошибки до рантайма. В React несколько мест, где это особенно окупается: пропсы, состояния, события.
Пропсы
Пропсы компонента типизируют явно. type или interface — оба работают; на практике type чуть гибче (union, пересечения), и команды чаще берут его по умолчанию.
type ButtonProps = {
variant: "primary" | "secondary";
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
};
function Button({ variant, disabled = false, onClick, children }: ButtonProps) {
return <button className={variant} disabled={disabled} onClick={onClick}>{children}</button>;
}
Главный приём — union строковых литералов ("primary" | "secondary") вместо string: компилятор не даст передать variant="primry", а редактор подскажет варианты. Чем уже тип, тем больше ошибок ловится статически. children типизируют как React.ReactNode.
Generics для переиспользуемых компонентов
Когда компонент работает с данными любого типа — список, таблица, селект, — его делают обобщённым (generic), чтобы он сохранял типы, а не сваливал всё в any.
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyOf: (item: T) => string;
};
function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
return <ul>{items.map((item) => <li key={keyOf(item)}>{renderItem(item)}</li>)}</ul>;
}
Теперь List<Product> знает, что renderItem получает Product, — типы текут сквозь компонент. Это убирает соблазн типизировать переиспользуемые компоненты как any[].
Дискриминированные union для состояний
Частая ошибка — набор необязательных пропсов, описывающих взаимоисключающие состояния (isLoading?, error?, data?), допускающий бессмысленные комбинации (и грузится, и ошибка). Дискриминированный union делает невозможные состояния невыразимыми.
type RemoteData<T> =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: T };
function ProductView({ state }: { state: RemoteData<Product> }) {
switch (state.status) {
case "loading": return <Spinner />;
case "error": return <ErrorBox message={state.error} />;
case "success": return <ProductCard {...state.data} />;
}
}
В каждой ветке switch TypeScript знает, какие поля доступны (state.data есть только в success). Это «делать недопустимые состояния непредставимыми» — тот же приём, что типобезопасность домена в backend.
События и ref
React-события и ref типизированы. Тип события зависит от элемента и обработчика; ref — от элемента, на который ссылается.
function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
return <input ref={inputRef} onChange={onChange} />;
}
React.ChangeEvent<HTMLInputElement>, React.FormEvent, React.MouseEvent — типы событий; useRef<HTMLInputElement>(null) типизирует ref. Редактор подскажет доступные свойства, а опечатка в e.taget не пройдёт компиляцию.
Утилитарные типы
Производные типы не дублируют, а выводят из существующих: Pick, Omit, Partial. Например, форма создания — это сущность без серверных полей:
type Product = { id: string; name: string; price: number; createdAt: string };
type CreateProductInput = Omit<Product, "id" | "createdAt">;
Меняется Product — CreateProductInput следует за ним. Это «единый источник истины» на уровне типов: один тип-родитель, остальные выведены.
Где это в UCP
Точная типизация — граница доверия внутри frontend, как типы между слоями backend: пропсы узкими union'ами, переиспользуемое — generics, состояния — дискриминированными union, производное — утилитарными типами. В связке с zod-схемами форм и типами из контракта API это даёт сквозную типобезопасность от сервера до экрана — то, что позволяет продукт-инженеру менять продукт, ловя рассинхрон компилятором, а не в проде. Дальше типы встречаются с логикой — в хуках.