Practical TypeScript habits that keep a growing React app refactorable: domain types, API boundaries, discriminated unions, and how to stop any from spreading.
Most TypeScript pain in React apps is not missing FC<>. It is untyped money, users, and orders flowing through the UI as Record<string, any>.
type Money = {
amount: number;
currency: 'USD' | 'EUR';
};
type ProductId = string & { readonly brand: unique symbol };
type Product = {
id: ProductId;
slug: string;
name: string;
price: Money;
status: 'draft' | 'active' | 'archived';
};
Branded IDs stop you from passing a cart ID into getProduct. Money as an object stops you from adding usd to cents by accident.
Keep these types in src/types or src/domain, not inside a random component file.
Network data is untrusted. Parse it once, then let the rest of the app use domain types.
import { z } from 'zod';
const ProductResponse = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
price_cents: z.number().int(),
currency: z.enum(['USD', 'EUR']),
status: z.enum(['draft', 'active', 'archived']),
});
export function toProduct(raw: unknown): Product {
const data = ProductResponse.parse(raw);
return {
id: data.id as ProductId,
slug: data.slug,
name: data.name,
price: { amount: data.price_cents / 100, currency: data.currency },
status: data.status,
};
}
When the backend renames price_cents, one parser fails. Twelve screens do not silently show $NaN.
Async UI is not { data, loading, error } with all three optional.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function ProductPanel({ state }: { state: AsyncState<Product> }) {
switch (state.status) {
case 'idle':
case 'loading':
return <Spinner />;
case 'error':
return <ErrorMessage error={state.error} />;
case 'success':
return <h1>{state.data.name}</h1>;
}
}
TypeScript will not let you read data on the loading branch. That is the feature.
The same pattern works for checkout steps: 'cart' | 'shipping' | 'payment' | 'review' with step-specific fields.
Prefer explicit props over spreading the entire domain object into a dumb component.
type PriceTagProps = {
price: Money;
strike?: Money;
size?: 'sm' | 'md';
};
export function PriceTag({ price, strike, size = 'md' }: PriceTagProps) {
return (
<span data-size={size}>
{strike ? <s>{formatMoney(strike)}</s> : null}
{formatMoney(price)}
</span>
);
}
If PriceTag needs Product, every Storybook story needs a fake product. If it needs Money, stories stay small.
Use ComponentPropsWithoutRef<'button'> to inherit native attributes instead of re-declaring onClick and disabled.
function isActive(product: Product): product is Product & { status: 'active' } {
return product.status === 'active';
}
const buyable = products.filter(isActive);
For optional chaining, prefer early returns in event handlers. Deep foo?.bar?.baz usually means the type is wrong one layer up.
Avoid as except at the system edge (URL params, JSON, branded IDs). If you need as in render, the prop type is lying.
Turning on strict in a Vue-to-React migration in week one can freeze the team. Sequence it:
strictNullChecks on new filesany via ESLint in src/features/**noUncheckedIndexedAccess once lists are typed// @ts-nocheck{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
TypeScript is a communication tool. The types should describe the business so the next engineer can change checkout without holding the whole store in their head.