After thirty-four days of JavaScript and TypeScript, you've likely written hundreds of type annotations. But many teams still spend weeks debugging type issues that careful choices at the start would have prevented. Basic types, done right: real-world lessons and examples shows you how to make decisions early that compound into cleaner, safer codebases.
Let inference work — Annotate parameters and exports, not every variable
TypeScript's type inference is powerful. Most of us know this. What we don't always do is trust it where it matters.
In production systems—especially in e-commerce and CRM platforms—we annotate far more than we need to. A function parameter that comes from an HTTP request must be annotated because the compiler can't guess the shape. A return type on a public API function must be annotated because callers depend on it. But the intermediate variables inside the function? Often they're fine.
Consider a checkout flow:
// Over-annotated (common in real codebases)
export function calculateTotal(items: CartItem[]): number {
const subtotal: number = items.reduce((sum: number, item: CartItem) => {
const price: number = item.price;
const qty: number = item.quantity;
const lineTotal: number = price * qty;
return sum + lineTotal;
}, 0);
const tax: number = subtotal * 0.08;
const total: number = subtotal + tax;
return total;
}
Every intermediate variable is annotated. The code is harder to read, and changes to CartItem or the math logic require touching many type labels. Here's the same function, letting inference work:
// Let inference do the work
export function calculateTotal(items: CartItem[]): number {
const subtotal = items.reduce((sum, item) => {
const lineTotal = item.price * item.quantity;
return sum + lineTotal;
}, 0);
const tax = subtotal * 0.08;
return subtotal + tax;
}
The compiler infers subtotal, tax, and lineTotal as numbers because it sees the operations on them. The code is cleaner and the intent is clearer. You still annotate the parameter and return type—the contract—but the implementation is lighter.
In a ride-hailing system we worked on, a data pipeline function had seventy-two intermediate variables, every one annotated. When we removed unnecessary annotations, the function shrunk visually, review times dropped, and bugs in the logic became easier to spot. The rule: annotate boundaries (parameters, exports, public types), not internals.
type vs interface — Both work. Interfaces for object shapes you extend; type for unions
This decision matters less than people think, but it still matters. Both type and interface describe object shapes in TypeScript. The practical difference: interfaces are designed to be extended; types handle unions and primitives cleanly.
Use interface when you're describing an object shape that other code will implement or extend:
// Good use of interface: extensible contract
export interface PaymentProvider {
charge(amount: number, currency: string): Promise<ChargeResult>;
refund(chargeId: string): Promise<RefundResult>;
}
// Teams implement this across modules
export class StripeProvider implements PaymentProvider {
async charge(amount: number, currency: string) {
// Stripe API call
}
async refund(chargeId: string) {
// Stripe refund logic
}
}
export class PayPalProvider implements PaymentProvider {
async charge(amount: number, currency: string) {
// PayPal API call
}
async refund(chargeId: string) {
// PayPal refund logic
}
}
Use type when you're composing unions, mapping over types, or describing something that isn't meant to be subclassed:
// Good use of type: unions and composition
export type PaymentStatus = 'pending' | 'succeeded' | 'failed' | 'refunded';
export type ChargeResult =
| { status: 'succeeded'; chargeId: string; processedAt: Date }
| { status: 'failed'; reason: string };
// Mapping and composition with types
export type PaymentConfig = {
provider: 'stripe' | 'paypal';
apiKey: string;
timeout: number;
};
export type TransactionRecord = PaymentConfig & {
chargeId: string;
amount: number;
currency: string;
};
In a trading system, we mixed interfaces and types freely and didn't suffer—but clarity improved when we separated concerns: interfaces for protocols and contracts, types for data shapes and unions. Your team should pick one rule and stick to it. The specific rule matters far less than consistency.
Literal types — 'paid' | 'pending' beats string
One of the highest-ROI changes we've made in production codebases is replacing bare string with literal unions. A single line prevents entire categories of bugs.
In a travel booking system, order status was typed as string:
// Weak typing: any string is valid
export function processOrder(orderId: string, status: string): void {
if (status === 'completed') {
// send confirmation email
} else if (status === 'cancelled') {
// process refund
} else {
// unknown state
}
}
// Caller can pass any string
processOrder('order-123', 'compleeted'); // Typo, no error
processOrder('order-456', 'COMPLETED'); // Case mismatch, no error
processOrder('order-789', 'in-progress'); // Handler missing, no error
With literal types:
// Strong typing: only valid states accepted
export type OrderStatus = 'completed' | 'cancelled' | 'pending' | 'shipped';
export function processOrder(orderId: string, status: OrderStatus): void {
switch (status) {
case 'completed':
sendConfirmationEmail(orderId);
break;
case 'cancelled':
processRefund(orderId);
break;
case 'pending':
queueNotification(orderId);
break;
case 'shipped':
updateTracking(orderId);
break;
}
}
// Caller must pass a valid literal
processOrder('order-123', 'completed'); // OK
processOrder('order-456', 'invalid'); // TS2345: not assignable to OrderStatus
Literal types work especially well with discriminated unions, where a single field tells you the shape of the rest:
// Discriminated union pattern
export type PaymentEvent =
| { type: 'charge.succeeded'; chargeId: string; amount: number }
| { type: 'charge.failed'; chargeId: string; reason: string }
| { type: 'refund.processed'; chargeId: string; refundId: string };
export function handlePaymentEvent(event: PaymentEvent): void {
switch (event.type) {
case 'charge.succeeded':
// event.chargeId and event.amount are available
recordRevenue(event.chargeId, event.amount);
break;
case 'charge.failed':
// event.reason is available
alertSupport(event.chargeId, event.reason);
break;
case 'refund.processed':
// event.refundId is available
updateInventory(event.chargeId);
break;
}
}
In a SaaS billing system, switching to literal types caught thirteen bugs in the first week that would have slipped past code review. Typos in status strings, mismatched enums across modules, and forgotten state transitions all surfaced immediately.
Arrays and tuples — string[] for lists, [string, number] for fixed pairs
Arrays and tuples serve different purposes. Confusing them costs you type safety.
Use string[] (or Array<string>) when the collection is homogeneous and open-ended:
// Array: variable length, same type
export function sendEmails(to: string[]): Promise<void> {
return Promise.all(
to.map(email => mailService.send(email))
);
}
// Caller can pass any number of emails
sendEmails(['alice@example.com', 'bob@example.com']);
sendEmails(['carol@example.com']);
Use tuples when the structure is fixed and each position has semantic meaning:
// Tuple: fixed length, specific types per position
export type GeoCoordinate = [latitude: number, longitude: number];
export type NameAndAge = [name: string, age: number];
export function getDistance(from: GeoCoordinate, to: GeoCoordinate): number {
const [lat1, lon1] = from;
const [lat2, lon2] = to;
// Haversine formula or similar
return Math.hypot(lat2 - lat1, lon2 - lon1);
}
const sfo: GeoCoordinate = [37.7749, -122.4194];
const oak: GeoCoordinate = [37.7213, -122.2725];
getDistance(sfo, oak);
// Tuple with optional elements
export type ApiResponse<T> = [data: T, error?: Error];
const [user, err] = await fetchUser(userId) as ApiResponse<User>;
Tuples shine in functions that return multiple related values. In a ride-hailing system, calculating a route returned a tuple of distance and estimated time—the named tuple made both values clear without wrapping them in an object.
One nuance: if a tuple grows beyond three elements, consider an object instead. A tuple with seven positions is harder to read than { distance, duration, cost, eta, driverId, vehicleId, polyline }. Tuples are best for tight, semantic pairs or triples.
Common mistakes
- Over-annotating locals: Annotating every variable inside a function bloats code and makes refactoring harder. Trust inference for internals.
- Mixing
anywith otherwise strong types: Oneanypollutes the entire scope. If you must use it, narrow it immediately:const safe: MyType = untypedValue as MyType; - Using string where literals fit: A status field typed as
stringinvites typos. Use'active' | 'inactive'from the start. - Confusing array and tuple semantics: A function returning
[User, Error]is not the same asUser[]. Be explicit about structure. - Forgetting to export types: If a type is part of your public API, export it. Don't force callers to infer the shape from function signatures.
Key takeaways
- Annotate boundaries—parameters and exports—but let inference handle the implementation details inside functions.
- Use
interfacefor extensible contracts; usetypefor unions and data composition. - Replace bare strings with literal types to prevent typos and catch invalid states at compile time.
- Distinguish arrays (variable length, homogeneous) from tuples (fixed structure, semantic positions).
- Consistency and clarity matter more than picking the "right" option—choose a pattern and stick to it across your codebase.






Comments
Loading comments…