TypeScript Is Worth the Pain (Even When You Want to Throw Your Laptop)
TypeScript Is Worth the Pain (Even When You Want to Throw Your Laptop)#
TypeScript is worth the pain. Even when it makes you want to throw your laptop out the window.
Starting on a new codebase that is typed to within an inch of its life — strict: true, no implicit any, custom ESLint rules that flag as casts — produces a rough first week. More time arguing with the type checker than writing features.
TL;DR
- Type your props explicitly. Always. No excuses
- Discriminated unions are the most powerful tool for component API design
- Event handler types follow a predictable pattern:
React.ChangeEvent<HTMLInputElement>,React.MouseEvent<HTMLButtonElement>, etc- When TypeScript pushes back, resist the
as anyurge -- that error is usually telling you something useful
By week three, the fighting stops. The type system catches bugs in PRs before reviewers do. A discriminated union prevents passing impossible prop combinations. When a shared component is refactored, TypeScript identifies exactly which call sites need updating.
The reason so many React developers have a frustrated relationship with TypeScript: they learned React first, then tried to add types to patterns designed without types in mind. The productive path runs the other direction. TypeScript applied to React idioms produces better component APIs than you'd design without it.
A senior developer I worked with who'd been doing TypeScript since the 2.x days put it well: "Stop trying to make TypeScript agree with your code. Make your code agree with TypeScript." Annoying advice. Also correct.
Typing Props: The Foundation#
Always declare an interface for your component props. Don't rely on inference from destructuring:
// AVOID: inferred types are invisible at a glance
function ProductCard({ name, price, currency, onAddToCart }: {
name: string;
price: number;
currency: string;
onAddToCart: (id: string) => void;
}) { ... }
// PREFER: named interface, self-documenting
interface ProductCardProps {
name: string;
price: number;
currency: string;
onAddToCart: (id: string) => void;
/** Optional badge text shown in the top-right corner */
badge?: string;
/** Disable interactions while a cart action is pending */
isLoading?: boolean;
}
function ProductCard({
name,
price,
currency,
onAddToCart,
badge,
isLoading = false,
}: ProductCardProps) {
return (
<article className="product-card">
{badge && <span className="product-card__badge">{badge}</span>}
<h3>{name}</h3>
<p>{currency}{price.toFixed(2)}</p>
<button
onClick={() => onAddToCart(name)}
disabled={isLoading}
aria-busy={isLoading}
>
{isLoading ? 'Adding...' : 'Add to cart'}
</button>
</article>
);
}
The JSDoc comments on optional props? That's free documentation that shows up in your editor on hover. I didn't appreciate this until I was reading someone else's component and didn't have to go find the implementation to understand the API.
Typing Children#
The children prop has different types depending on what you accept:
import { ReactNode, ReactElement } from 'react';
import { PropsWithChildren } from 'react';
// Most permissive: accepts anything React can render
interface ContainerProps {
children: ReactNode;
}
// Only React elements (not strings/numbers)
interface WrapperProps {
children: ReactElement;
}
// PropsWithChildren<T> -- the shortcut I use most
interface CardProps {
title: string;
variant?: 'outlined' | 'filled';
}
function Card({ title, variant = 'outlined', children }: PropsWithChildren<CardProps>) {
return (
<div className={`card card--${variant}`}>
<div className="card__header">
<h3>{title}</h3>
</div>
<div className="card__body">{children}</div>
</div>
);
}
Event Handlers: The Part That Drives Everyone Nuts#
Look, I'm going to be honest. I still google React event handler types sometimes. But the pattern IS consistent once you internalize it:
import { ChangeEvent, MouseEvent, FormEvent, KeyboardEvent } from 'react';
// Input changes
function SearchBar() {
function handleChange(e: ChangeEvent<HTMLInputElement>) {
console.log(e.target.value);
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
console.log(e.target.value);
}
return (
<div>
<input type="text" onChange={handleChange} />
<select onChange={handleSelectChange}>
<option value="newest">Newest</option>
<option value="price-asc">Price: Low to High</option>
</select>
</div>
);
}
// Form submission
function CheckoutForm() {
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const email = data.get('email') as string; // yeah this cast is ugly but FormData returns FormDataEntryValue
}
return <form onSubmit={handleSubmit}>{/* fields */}</form>;
}
// Button clicks
function ExpandableSection({ label, content }: { label: string; content: string }) {
function handleToggle(e: MouseEvent<HTMLButtonElement>) {
const button = e.currentTarget;
button.setAttribute('aria-expanded',
button.getAttribute('aria-expanded') === 'true' ? 'false' : 'true'
);
}
return (
<div>
<button onClick={handleToggle} aria-expanded="false">
{label}
</button>
<p>{content}</p>
</div>
);
}
The pattern: React.[EventType]Event<HTML[Element]Element>. Once you see it, you can't unsee it.
Discriminated Unions: The Game Changer#
This is the pattern that changed how I design component APIs. Instead of boolean flags that can conflict with each other, use a discriminated union:
// PROBLEMATIC: what does isLoading + isError mean? Who knows!
interface StatusBadgeProps {
isLoading?: boolean;
isSuccess?: boolean;
isError?: boolean;
message: string;
}
// BETTER: exactly one state is possible at a time
type StatusBadgeProps =
| { status: 'loading' }
| { status: 'success'; message: string }
| { status: 'error'; message: string; onRetry?: () => void };
function StatusBadge(props: StatusBadgeProps) {
if (props.status === 'loading') {
return <span className="badge badge--loading">Loading...</span>;
}
if (props.status === 'error') {
return (
<span className="badge badge--error">
{props.message}
{props.onRetry && (
<button onClick={props.onRetry}>Retry</button>
)}
</span>
);
}
return <span className="badge badge--success">{props.message}</span>;
}
// TypeScript enforces this:
<StatusBadge status="loading" /> // OK
<StatusBadge status="success" message="Saved!" /> // OK
<StatusBadge status="error" message="Failed" onRetry={handleRetry} /> // OK
// <StatusBadge status="error" /> // ERROR: message is required for error
This is like defining a proper state space -- no impossible states can exist. The best way to avoid bugs is to make them structurally impossible. Discriminated unions do exactly that.
Here's a more complex example -- a button that can be either a <button> or an <a>:
type ButtonAsButton = React.ComponentPropsWithoutRef<'button'> & {
as?: 'button';
};
type ButtonAsLink = React.ComponentPropsWithoutRef<'a'> & {
as: 'link';
href: string;
};
type ButtonProps = ButtonAsButton | ButtonAsLink;
function Button({ as, children, ...props }: ButtonProps) {
if (as === 'link') {
return (
<a className="btn" {...(props as React.ComponentPropsWithoutRef<'a'>)}>
{children}
</a>
);
}
return (
<button className="btn" {...(props as React.ComponentPropsWithoutRef<'button'>)}>
{children}
</button>
);
}
Generic Components#
When a component works with data of any shape, generics keep type safety through the whole chain:
interface TableColumn<T> {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
width?: number;
}
interface DataTableProps<T extends { id: string }> {
rows: T[];
columns: TableColumn<T>[];
onRowClick?: (row: T) => void;
isLoading?: boolean;
}
function DataTable<T extends { id: string }>({
rows,
columns,
onRowClick,
isLoading = false,
}: DataTableProps<T>) {
if (isLoading) {
return <div role="status" aria-live="polite">Loading data...</div>;
}
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)} style={{ width: col.width }}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{ cursor: onRowClick ? 'pointer' : undefined }}
>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// TypeScript infers the generic from rows
interface Order {
id: string;
customerName: string;
amount: number;
status: 'pending' | 'shipped' | 'delivered';
}
function OrdersPage({ orders }: { orders: Order[] }) {
const columns: TableColumn<Order>[] = [
{ key: 'customerName', header: 'Customer' },
{ key: 'amount', header: 'Amount', render: (val) => `$${Number(val).toFixed(2)}` },
{ key: 'status', header: 'Status' },
];
return <DataTable rows={orders} columns={columns} />;
}
Generics can feel like ceremony when you first encounter them. But in a React component library, generics mean your table component works with ANY data shape while catching type errors at the call site. Worth the initial friction.
Utility Types That Actually Help#
// Omit: extend an existing type without duplicating fields
interface BaseInputProps {
label: string;
error?: string;
required?: boolean;
}
type CurrencyInputProps = BaseInputProps &
Omit<React.ComponentPropsWithoutRef<'input'>, 'type'> & {
currency: string;
decimalPlaces?: number;
};
function CurrencyInput({ label, error, currency, decimalPlaces = 2, required, ...inputProps }: CurrencyInputProps) {
return (
<div className="field">
<label>
{label}{required && <span aria-hidden>*</span>}
<div className="field__input-wrapper">
<span className="field__currency">{currency}</span>
<input type="number" step={Math.pow(10, -decimalPlaces)} {...inputProps} />
</div>
</label>
{error && <p role="alert" className="field__error">{error}</p>}
</div>
);
}
Common Type Errors and What They Mean#
Type 'string | undefined' is not assignable to type 'string'
// TypeScript is right. user might be null.
function ProfileHeader({ user }: { user: User | null }) {
return <h1>{user.name}</h1>; // Error!
}
// Handle the null case. TypeScript was protecting you.
function ProfileHeader({ user }: { user: User | null }) {
if (!user) return null;
return <h1>{user.name}</h1>;
}
Discriminated union narrowing:
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function processResult<T>(result: Result<T>) {
if (result.success) {
console.log(result.data); // OK: narrowed to { success: true; data: T }
} else {
console.log(result.error); // OK: narrowed to { success: false; error: string }
}
}
Ref types with forwardRef:
import { forwardRef } from 'react';
// <RefType, PropsType>
const FancyInput = forwardRef<HTMLInputElement, { placeholder: string }>(
({ placeholder }, ref) => (
<input ref={ref} placeholder={placeholder} className="fancy-input" />
)
);
FancyInput.displayName = 'FancyInput';
Common Pitfalls#
Reaching for as every time TypeScript complains. Every as SomeType is a promise to TypeScript that you know better. Sometimes you do. More often, you haven't modeled the types correctly. Use type narrowing (if checks, type predicates) instead of casts.
I spent an entire Saturday debugging a crash that was caused by an as cast. The value wasn't what I told TypeScript it was. Lesson learned the hard way.
One any spreads everywhere. If a function accepts any and returns any, TypeScript can't track types through it. One any in a generic chain infects everything downstream. Use unknown when you genuinely don't know the type, then narrow.
Not enabling strict: true. Without strict mode, TypeScript misses the most valuable checks -- null safety, implicit any, etc. Starting without strict mode is debt you'll pay later. It's painful at first. After a few weeks you can't imagine going back.
When TypeScript pushes back, my first instinct is finally "what is it telling me?" instead of "how do I shut it up?" Nine times out of ten, the error points at a real issue: a value that might be null, a state that shouldn't be possible, a prop combination that doesn't make sense. The tenth time? Fine, throw in an as cast and leave a // TODO comment. But at least you know you're making a conscious choice instead of a reflexive one.