Skip to main content

React TypeScript Union and Literal Types: State Machines

Union types and literal types are TypeScript's tools for modeling alternatives. A union type says "this value is one of several types," and a literal type says "this value is exactly this string or number." Together, they enable you to build state machines and data models that are so precise that impossible states cannot exist in your code—the compiler prevents them before runtime.

What Are Union Types and When Should You Use Them?

A union type describes a value that can be one of several types. You combine types with the pipe operator (|). Union types are essential for modeling real-world alternatives:

// Union of primitives
type Status = "idle" | "loading" | "success" | "error";

// Union of types
type Result<T> = { success: true; data: T } | { success: false; error: string };

// Union of interfaces
interface User {
type: "user";
id: number;
name: string;
}

interface Admin {
type: "admin";
id: number;
permissions: string[];
}

type Account = User | Admin;

Union types force you to handle all cases. When you have a Status value, TypeScript requires you to handle "idle", "loading", "success", and "error":

function renderStatus(status: Status) {
if (status === "idle") return <div>Not started</div>;
if (status === "loading") return <div>Loading...</div>;
if (status === "success") return <div>Success!</div>;
if (status === "error") return <div>Error occurred</div>;
// TypeScript knows all cases are covered
}

Without union types, you might forget a case. With them, the compiler catches the mistake.

What Are Literal Types?

A literal type is a precise type for a specific string or number. Instead of typing something as string, you type it as "hello" or "goodbye". This is useful for enum-like values where only certain strings are valid:

// Literal types
type Role = "admin" | "user" | "guest";
type Priority = 1 | 2 | 3 | 4 | 5;
type Method = "GET" | "POST" | "PUT" | "DELETE";

// Variables must match exactly
const myRole: Role = "admin"; // Valid
// const invalid: Role = "superuser"; // ERROR: "superuser" is not a valid role

// Literal types work with numbers too
type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;
const statusCode: HttpStatus = 200; // Valid
// const bad: HttpStatus = 201; // Valid, but 999 would be invalid

Literal types are stricter than enums and integrate seamlessly with functions and state. Many React developers prefer literal type unions over TypeScript enum declarations.

What Are Discriminated Unions and Why Are They Powerful?

A discriminated union combines a literal type field with different properties depending on that field's value. This pattern makes impossible states impossible to represent:

// Each state has a 'status' field (the discriminant) that tells us what other fields to expect
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };

// A variable of this type MUST match one of the four states exactly
function renderAsync<T>(state: AsyncState<T>) {
switch (state.status) {
case "idle":
// TypeScript knows state is { status: 'idle' }
return <div>Not started</div>;

case "loading":
// TypeScript knows state is { status: 'loading' }
return <div>Loading...</div>;

case "success":
// TypeScript knows state is { status: 'success'; data: T }
// We can safely access state.data
return <div>Data: {state.data}</div>;

case "error":
// TypeScript knows state is { status: 'error'; error: string }
// We can safely access state.error
return <div>Error: {state.error}</div>;
}
}

The power of discriminated unions is that you cannot accidentally mix states. You cannot have { status: 'success' } without a data field, and you cannot have data without status: 'success'. The compiler prevents these mistakes.

How Do You Use Unions in React Component Props?

Union types in props let you describe components that behave differently based on a prop value. This is especially powerful with discriminated unions:

// A button that can show text or an icon or both
type ButtonContent =
| { variant: "text"; label: string }
| { variant: "icon"; icon: React.ReactNode }
| { variant: "text-icon"; label: string; icon: React.ReactNode };

interface ButtonProps {
content: ButtonContent;
onClick: () => void;
}

function Button({ content, onClick }: ButtonProps) {
return (
<button onClick={onClick}>
{content.variant === "text" && content.label}
{content.variant === "icon" && content.icon}
{content.variant === "text-icon" && (
<>
{content.icon} {content.label}
</>
)}
</button>
);
}

// Use cases
<Button
content={{ variant: "text", label: "Click me" }}
onClick={() => {}}
/>

<Button
content={{ variant: "icon", icon: <Save /> }}
onClick={() => {}}
/>

<Button
content={{ variant: "text-icon", label: "Save", icon: <Save /> }}
onClick={() => {}}
/>

// ERROR: missing 'label' when variant is 'text'
// <Button content={{ variant: "text" }} onClick={() => {}} />

This pattern is called "discriminated union props" and is a best practice for flexible, type-safe components.

How Do You Build a Finite State Machine with Types?

Discriminated unions model finite state machines—systems that can be in one state at a time and transition between states. TypeScript can ensure you never enter an invalid state:

// Payment processing state machine
type PaymentState =
| { state: "idle" }
| { state: "processing"; transactionId: string }
| { state: "success"; transactionId: string; orderId: number }
| { state: "failed"; error: string };

interface PaymentContext {
amount: number;
currency: string;
current: PaymentState;
}

function processPayment(
context: PaymentContext,
action: "submit" | "cancel" | "retry"
): PaymentState {
const { current } = context;

if (action === "submit" && current.state === "idle") {
// Valid: idle -> processing
return {
state: "processing",
transactionId: Math.random().toString(),
};
}

if (
action === "retry" &&
current.state === "failed"
) {
// Valid: failed -> processing
return {
state: "processing",
transactionId: Math.random().toString(),
};
}

// Invalid transitions return the current state
return current;
}

State machines prevent bugs by making invalid transitions impossible. TypeScript ensures you handle each state and transition correctly.

How Do You Use Type Guards with Unions?

When you have a union type, TypeScript narrows the type after you check a discriminant. This is called type narrowing. The most reliable way is to check the discriminant field:

type Data =
| { type: "user"; name: string; id: number }
| { type: "post"; title: string; content: string };

function displayData(data: Data) {
// Before the check, data could be either type
// After the check, TypeScript knows which type it is

if (data.type === "user") {
console.log(`User: ${data.name} (ID: ${data.id})`);
} else if (data.type === "post") {
console.log(`Post: ${data.title}\n${data.content}`);
}
}

// You can also narrow with instanceof for class types
type FileData = File | Blob;

function handleFile(file: FileData) {
if (file instanceof File) {
// TypeScript knows file is File (has .name, .lastModified, etc.)
console.log(file.name);
} else {
// TypeScript knows file is Blob (only)
console.log(file.size);
}
}

Type guards are essential for working safely with unions. Always narrow types before accessing type-specific properties.

Key Takeaways

  • Union types (A | B) describe alternatives; the compiler requires you to handle all cases.
  • Literal types ("success" | "error") are precise alternatives to plain strings or numbers.
  • Discriminated unions combine a discriminant field with type-specific properties, making invalid states impossible.
  • Use discriminated unions for React component props when different prop combinations represent different behaviors.
  • Type guards (checking the discriminant) let TypeScript narrow unions to their specific type, enabling safe property access.

Frequently Asked Questions

Should I use TypeScript enum or literal union types?

Prefer literal union types over enum. Literal unions are simpler, integrate better with JSON serialization, and allow more flexibility:

// Preferred: literal union
type Status = "idle" | "loading" | "success" | "error";

// Older style: enum
enum StatusEnum {
Idle = "idle",
Loading = "loading",
Success = "success",
Error = "error",
}

Both work, but literal unions are more idiomatic in modern React code.

Can I use string literal unions in API responses?

Yes, and this is a powerful pattern. If your API returns { status: "success", data: ... } or { status: "error", message: ... }, type it as a discriminated union and you get type safety across the entire response handling:

interface ApiResponse<T> {
status: "success" | "error";
data?: T;
error?: string;
}

// But better: discriminated union
type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; error: string };

How do I handle a union where not all cases are exhaustive?

Use a default case to ensure you handle all branches:

function handle(status: Status) {
switch (status) {
case "idle":
return "Not started";
case "loading":
return "Loading";
case "success":
return "Done";
case "error":
return "Error";
default:
// This should never happen if Status is exhaustive
// But TypeScript will flag if you add a new case and forget to handle it
const _exhaustiveCheck: never = status;
return _exhaustiveCheck;
}
}

This pattern ensures you handle every case in a union.

Can a union type have thousands of possible values?

Technically yes, but it becomes impractical. Union types with more than 10-15 alternatives are hard to read and maintain. Use plain string or number for large value sets and validate at runtime if needed.

Further Reading