Modeling the Real World with TypeScript
When Your Code Misses the Real World
Have you ever written a type for something like a payment status—say, "pending" or "completed"—only to realize later it didn’t account for "failed" or "refunded"? I’ve been there. Early in my career, I built a dashboard where the UI broke because I didn’t model every possible state. Users saw errors, and I spent hours debugging. Real-world data—like user profiles, API responses, or form inputs—is messy, and vanilla JavaScript leaves you guessing.
TypeScript changes that. Following our first article, “TypeScript as Your Safety Net,” where we caught bugs with types, now we’ll level up to model real-world data cleanly and safely. Tools like type, interface, union types, intersection types, and discriminated unions let you represent complex states—like payment flows or form validation—with confidence. No more surprise bugs or fragile code. Let’s look at how to do it right—with examples that mirror real applications.
type vs interface: Which Should You Use?
TypeScript’s type and interface both let you define shapes for your data, but they have subtle differences. Understanding when to use each will make your code clearer and more maintainable.
Where They Overlap Both type and interface can describe object shapes, like a user profile. They’re often interchangeable for simple cases. For example, these are nearly identical:
interface User {
id: number;
name: string;
}
type User = {
id: number;
name: string;
};
Where They Differ
When to Choose
Real-World Example Imagine modeling a User for a dashboard. An interface is great for a stable object shape:
interface User {
id: number;
name: string;
email?: string;
}
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
Now, suppose you need a GuestUser with limited fields. Use type to create a union or alias:
type GuestUser = {
name: string;
isGuest: true;
};
function displayUser(user: User | GuestUser) {
return user.name; // Safe, both have name
}
Does It Matter Early On? For beginners, the distinction is minor—pick one and keep coding. Most teams use interface for objects and type for unions or utilities. As you grow, interface shines for extendable domain models (like User), while type unlocks creative combinations (like string | number).
Choose based on clarity and team conventions. Next, we’ll see how type powers union types to model real-world states.
Making Data Safe with Literal and Union Types
Literal and union types are TypeScript’s secret sauce for modeling precise, real-world data. They let you define exactly what values are allowed, catching mistakes before they sneak into your code.
What Are They? Literal types specify exact values, like "loading" or 42. Union types combine them with |, saying “it’s one of these options.” For example:
type RequestStatus = "loading" | "success" | "error";
This means RequestStatus can only be "loading", "success", or "error". Try assigning "oops"—TypeScript will flag it instantly.
Why They’re Awesome
Real-World Example Let’s model a UI theme toggle with union types. Imagine a dashboard that supports light, dark, or system themes:
type ThemeMode = "light" | "dark" | "system";
function setTheme(mode: ThemeMode) {
if (mode === "light") {
document.body.className = "light-theme";
} else if (mode === "dark") {
document.body.className = "dark-theme";
} else {
document.body.className = "system-theme";
}
}
setTheme("light"); // ✅ Works
setTheme("blue"); // ❌ Error: Argument 'blue' is not assignable to ThemeMode
This is perfect for real-world cases like:
Union types force you to handle every case explicitly, making your code bulletproof. For example, forgetting to handle "system" in setTheme would trigger a TypeScript error if you use strict mode. Next, we’ll combine types to model even more complex data.
Combining Types with Intersections
Intersection types let you combine multiple types into one using &. Think of it as saying, “I want all of these properties together.” This is perfect for building complex data models from reusable pieces, making your code modular and maintainable.
How It Works An intersection type merges the fields of two or more types. If you have a type for IDs and another for timestamps, you can combine them to describe an entity that has both:
interface HasId {
id: number;
}
interface HasTimestamps {
createdAt: Date;
updatedAt: Date;
}
type Entity = HasId & HasTimestamps;
Now, an Entity must have id, createdAt, and updatedAt.
Real-World Example Imagine modeling a blog post in a CMS. You want every post to have an ID and timestamps, but also post-specific fields like title and content. Here’s how intersections help:
interface HasId {
id: number;
}
interface HasTimestamps {
createdAt: Date;
updatedAt: Date;
}
interface PostDetails {
title: string;
content: string;
}
type BlogPost = HasId & HasTimestamps & PostDetails;
const post: BlogPost = {
id: 1,
createdAt: new Date("2025-05-01"),
updatedAt: new Date("2025-05-02"),
title: "TypeScript Tips",
content: "Learn TypeScript today!"
};
// TypeScript ensures all fields are present
console.log(`${post.title} was created on ${post.createdAt}`);
Why This Matters Intersection types shine for composition. Instead of writing one giant type, you reuse smaller ones like HasId across entities (e.g., users, posts, comments). This keeps your code DRY and flexible. For example, a User might combine HasId, HasTimestamps, and HasProfile (with name and email). If you forget a field, TypeScript catches it immediately.
Intersections are your tool for building complex models from simple, reusable parts. Next, we’ll tackle discriminated unions to handle dynamic states safely.
Modeling State Machines with Discriminated Unions
Discriminated unions are TypeScript’s superpower for modeling complex, variant data—like UI states or form inputs—safely. They let you define multiple shapes that share a common “discriminant” property, making it easy to handle every possible case without errors.
What Are They? A discriminated union is a union of types (using |) where each type has a shared property (the discriminant) with a unique literal value. TypeScript uses this property to narrow down the type, ensuring you handle each variant correctly.
Recommended by LinkedIn
Real-World Example: FormField Imagine a form with different input types: text, checkbox, or dropdown. A discriminated union models this perfectly:
interface TextField {
type: "text";
value: string;
placeholder?: string;
}
interface CheckboxField {
type: "checkbox";
checked: boolean;
}
interface DropdownField {
type: "dropdown";
options: string[];
selected: string;
}
type FormField = TextField | CheckboxField | DropdownField;
function renderField(field: FormField) {
switch (field.type) {
case "text":
return `<input type="text" value="${field.value}" placeholder="${field.placeholder || ""}" />`;
case "checkbox":
return `<input type="checkbox" ${field.checked ? "checked" : ""} />`;
case "dropdown":
return `<select>${field.options.map(opt => `<option ${opt === field.selected ? "selected" : ""}>${opt}</option>`).join("")}</select>`;
default:
// TypeScript ensures all cases are handled
throw new Error("Unknown field type");
}
}
// Usage
const text: FormField = { type: "text", value: "Hello", placeholder: "Enter text" };
const checkbox: FormField = { type: "checkbox", checked: true };
console.log(renderField(text)); // <input type="text" value="Hello" placeholder="Enter text" />
console.log(renderField(checkbox)); // <input type="checkbox" checked />
How It Helps The type property acts as the discriminant. In the switch statement, TypeScript narrows the type based on field.type. When you check field.type === "text", TypeScript knows field is a TextField and suggests value and placeholder. Try accessing field.options in the "text" case—TypeScript will flag it as an error.
This is ideal for:
Why It’s Safe TypeScript’s exhaustive checking ensures you handle every case in the switch. Forget to handle "dropdown"? You’ll get a compile-time error. This makes discriminated unions perfect for state machines, where missing a state could crash your app.
Discriminated unions bring clarity and safety to dynamic data. Next, we’ll apply these tools to a full real-world example.
Bringing It Together in the Real World
Let’s tie everything together with a real-world example: modeling a PaymentStatus for an e-commerce app. Payments can be tricky—they have different states (pending, success, failed) with unique data for each. TypeScript’s unions and interfaces make this clean and safe.
Modeling PaymentStatus We’ll use a discriminated union to represent the three states, each with its own metadata:
interface PendingPayment {
status: "pending";
orderId: string;
initiatedAt: Date;
}
interface SuccessfulPayment {
status: "success";
orderId: string;
amount: number;
completedAt: Date;
}
interface FailedPayment {
status: "failed";
orderId: string;
errorCode: string;
errorMessage: string;
}
type PaymentStatus = PendingPayment | SuccessfulPayment | FailedPayment;
function processPayment(payment: PaymentStatus): string {
switch (payment.status) {
case "pending":
return `Payment ${payment.orderId} is pending since ${payment.initiatedAt.toLocaleDateString()}`;
case "success":
return `Payment ${payment.orderId} for $${payment.amount} completed on ${payment.completedAt.toLocaleDateString()}`;
case "failed":
return `Payment ${payment.orderId} failed: ${payment.errorMessage} (Code: ${payment.errorCode})`;
}
}
// Usage
const pending: PaymentStatus = { status: "pending", orderId: "123", initiatedAt: new Date() };
const success: PaymentStatus = { status: "success", orderId: "124", amount: 99.99, completedAt: new Date() };
const failed: PaymentStatus = { status: "failed", orderId: "125", errorCode: "E401", errorMessage: "Card declined" };
console.log(processPayment(pending)); // Payment 123 is pending since 5/4/2025
console.log(processOptions(processPayment(success)); // Payment 124 for $99.99 completed on 5/4/2025
console.log(processPayment(failed)); // Payment 125 failed: Card declined (Code: E401)
Why This Works The status discriminant ("pending", "success", "failed") lets TypeScript narrow the type in the switch. For example, in the "success" case, TypeScript knows payment has amount and completedAt, so you get autocompletion and error checking. Try accessing errorCode in the "success" branch—TypeScript will stop you with an error. The exhaustive switch ensures all states are handled, preventing bugs like forgetting to handle "failed".
This model is perfect for real apps:
In an e-commerce dashboard, this lets you safely render payment statuses, log errors, or trigger notifications, knowing TypeScript has your back. Next, we’ll cover common mistakes to avoid when modeling like this.
🚫 Common Pitfalls to Watch Out For
TypeScript’s type system is powerful, but beginners can trip over a few common mistakes when modeling data. Here are three to avoid, with quick fixes to keep your code safe.
1. Forgetting the Discriminant in Unions Without a shared discriminant (like type or status), TypeScript can’t narrow union types effectively. For example:
interface TextField { value: string; }
interface CheckboxField { checked: boolean; }
type FormField = TextField | CheckboxField; // No discriminant!
function render(field: FormField) {
// How do I know which type it is? TypeScript can’t help much.
}
Fix: Add a literal type property to each member:
interface TextField { type: "text"; value: string; }
interface CheckboxField { type: "checkbox"; checked: boolean; }
Now TypeScript narrows based on field.type, making your code safer.
2. Using any for Inputs or APIs It’s tempting to slap any on API responses or form inputs, but this disables type checking. For example:
function processResponse(data: any) {
return data.result; // No safety—could be undefined!
}
Fix: Use unknown and narrow with checks, or define an interface:
interface ApiResponse { result: string; }
function processResponse(data: ApiResponse) {
return data.result; // TypeScript ensures result exists
}
3. Making Unions Too Wide Broad unions like string | number or "pending" | string allow too many values, reducing safety. For example:
type Status = "pending" | string; // Allows "oops", "whatever"
Fix: Use precise literal unions:
type Status = "pending" | "success" | "failed"; // Only these values
By avoiding these pitfalls—missing discriminants, any overuse, and vague unions—you’ll create models that are precise and bug-resistant. Next, let’s put your skills to the test with a challenge!
🧩 Your Turn — Model This!
Time to flex your TypeScript modeling skills! Here’s a challenge to bring together everything we’ve covered. Your task is to model a Notification type for a web app, which can represent three states:
Use interfaces and a discriminated union to define the Notification type. Then, write a function that processes the notification and returns a display string (e.g., "Error: Failed to save" or "Loading..."). Make sure it’s type-safe with a discriminant like type.
Here’s a starter:
interface ErrorNotification {
// Your code here
}
interface SuccessNotification {
// Your code here
}
// Add more as needed
type Notification = // Your union here
function displayNotification(notification: Notification): string {
// Your logic here
}
Post your solution in the comments below! I’d love to see how you model it and handle the cases. This is a great way to practice modeling real UI states. Share your code, and let’s discuss what makes it shine!
Next Up: Reusable Code with Generics
We’ve seen how TypeScript’s interfaces, union types, intersection types, and discriminated unions bring safety and structure to real-world data like payment statuses and form fields. These tools help you model complex states—like UI notifications or API responses—with precision, catching bugs before they hit production. Your code becomes clearer, and your confidence grows.
But what if you want to reuse those models across different data types? That’s where generics come in. In the next post, we’ll explore how generics let you create flexible, reusable types—like a single List type that works for users, products, or anything else. Want to write cleaner, more scalable code? Follow for the next post: Reusable Code with Generics in TypeScript! Share your notification challenge solutions in the comments, and let’s keep leveling up together!