From Zero to AI

Lesson 3.3: Union and Literal Types

Duration: 55 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Create union types that accept multiple type options
  • Use literal types for exact value constraints
  • Narrow types using type guards
  • Combine unions and literals for precise type definitions

Union Types: Either This or That

A union type allows a variable to hold values of different types. Use the pipe symbol (|) to combine types:

let id: string | number;

id = 'user_123'; // OK - string
id = 456; // OK - number
id = true; // Error! Type 'boolean' is not assignable to type 'string | number'

Think of union types as an "or" relationship: the value can be string OR number.


Common Union Type Patterns

Nullable Values

The most common union is with null or undefined:

let username: string | null = null;
username = 'Alice'; // OK
username = null; // OK - explicitly no value

let age: number | undefined;
age = 25; // OK
age = undefined; // OK - not yet set

Function Parameters

Allow different input types:

function formatId(id: string | number): string {
  return `ID: ${id}`;
}

formatId('abc123'); // OK
formatId(789); // OK
formatId(true); // Error!

Return Types

When a function might return different types:

function findUser(id: number): { name: string } | null {
  if (id === 1) {
    return { name: 'Alice' };
  }
  return null; // User not found
}

Working with Union Types

When you have a union type, TypeScript only allows operations that are valid for ALL types in the union:

function printId(id: string | number) {
  // This works - both strings and numbers can be converted to strings
  console.log(`ID: ${id}`);

  // This fails - toUpperCase only exists on strings
  console.log(id.toUpperCase()); // Error! Property 'toUpperCase' does not exist on type 'number'
}

To use type-specific methods, you need to narrow the type first.


Type Narrowing

Type narrowing is how you tell TypeScript which specific type you are working with. TypeScript tracks these checks and narrows the type automatically.

Using typeof

function printId(id: string | number) {
  if (typeof id === 'string') {
    // Inside this block, TypeScript knows id is a string
    console.log(id.toUpperCase());
  } else {
    // Here TypeScript knows id is a number
    console.log(id.toFixed(2));
  }
}

Using Truthiness

function printName(name: string | null) {
  if (name) {
    // name is definitely a string here (not null)
    console.log(`Hello, ${name.toUpperCase()}!`);
  } else {
    console.log('Hello, stranger!');
  }
}

Using Equality Checks

function processValue(value: string | number | boolean) {
  if (value === true) {
    // value is true
    console.log("It's true!");
  } else if (value === false) {
    // value is false
    console.log("It's false!");
  } else {
    // value is string | number
    console.log(`Value: ${value}`);
  }
}

Literal Types: Exact Values

Literal types restrict a variable to specific values, not just types:

let direction: 'north' | 'south' | 'east' | 'west';

direction = 'north'; // OK
direction = 'south'; // OK
direction = 'up'; // Error! Type '"up"' is not assignable to type '"north" | "south" | "east" | "west"'

String Literals

type Status = 'pending' | 'approved' | 'rejected';

let orderStatus: Status = 'pending';
orderStatus = 'approved'; // OK
orderStatus = 'cancelled'; // Error! Not in the union

Number Literals

type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

let roll: DiceRoll = 4; // OK
roll = 7; // Error! Type '7' is not assignable

Boolean Literal

Less common, but possible:

type AlwaysTrue = true;
let mustBeTrue: AlwaysTrue = true;
mustBeTrue = false; // Error!

Combining Unions and Literals

Literal types become powerful when combined with unions:

Example: HTTP Status Codes

type SuccessCode = 200 | 201 | 204;
type ErrorCode = 400 | 401 | 403 | 404 | 500;
type StatusCode = SuccessCode | ErrorCode;

function handleResponse(code: StatusCode) {
  if (code === 200 || code === 201 || code === 204) {
    console.log('Success!');
  } else {
    console.log('Error occurred');
  }
}

Example: User Roles

type Role = 'admin' | 'editor' | 'viewer';

function checkPermission(role: Role, action: string): boolean {
  if (role === 'admin') {
    return true; // Admin can do anything
  }
  if (role === 'editor') {
    return action !== 'delete';
  }
  return action === 'view';
}

checkPermission('admin', 'delete'); // OK
checkPermission('guest', 'view'); // Error! "guest" is not a valid Role

Example: Configuration Options

type Theme = 'light' | 'dark' | 'system';
type Language = 'en' | 'es' | 'fr' | 'de';

interface Settings {
  theme: Theme;
  language: Language;
  notifications: boolean;
}

const userSettings: Settings = {
  theme: 'dark',
  language: 'en',
  notifications: true,
};

Discriminated Unions

A powerful pattern combining literal types with objects. Each object in the union has a common property with a literal type that identifies it:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'square'; size: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript knows shape has radius here
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      // TypeScript knows shape has width and height here
      return shape.width * shape.height;
    case 'square':
      // TypeScript knows shape has size here
      return shape.size ** 2;
  }
}

const circle: Shape = { kind: 'circle', radius: 5 };
const rectangle: Shape = { kind: 'rectangle', width: 10, height: 20 };

console.log(calculateArea(circle)); // 78.54...
console.log(calculateArea(rectangle)); // 200

The kind property is called the "discriminant" - it tells TypeScript which shape we are dealing with.


Practical Examples

Example 1: API Response Handler

type ApiResponse =
  | { status: 'success'; data: string[] }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      console.log('Data:', response.data.join(', '));
      break;
    case 'error':
      console.log('Error:', response.message);
      break;
    case 'loading':
      console.log('Loading...');
      break;
  }
}

handleResponse({ status: 'success', data: ['item1', 'item2'] });
handleResponse({ status: 'error', message: 'Not found' });
handleResponse({ status: 'loading' });

Example 2: Event System

type AppEvent =
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string }
  | { type: 'scroll'; direction: 'up' | 'down' };

function logEvent(event: AppEvent) {
  switch (event.type) {
    case 'click':
      console.log(`Clicked at (${event.x}, ${event.y})`);
      break;
    case 'keypress':
      console.log(`Key pressed: ${event.key}`);
      break;
    case 'scroll':
      console.log(`Scrolled ${event.direction}`);
      break;
  }
}

Example 3: Form Validation

type ValidationResult = { valid: true } | { valid: false; errors: string[] };

function validateEmail(email: string): ValidationResult {
  const errors: string[] = [];

  if (!email.includes('@')) {
    errors.push('Email must contain @');
  }
  if (email.length < 5) {
    errors.push('Email too short');
  }

  if (errors.length > 0) {
    return { valid: false, errors };
  }
  return { valid: true };
}

const result = validateEmail('test');
if (result.valid) {
  console.log('Email is valid!');
} else {
  console.log('Errors:', result.errors.join(', '));
}

Exercises

Exercise 1: Create a Union Type

Create a type for a variable that can hold either a user ID (number) or a username (string), then write a function that accepts this type and returns a formatted string.

Solution
type UserIdentifier = number | string;

function formatIdentifier(id: UserIdentifier): string {
  if (typeof id === 'number') {
    return `User #${id}`;
  }
  return `@${id}`;
}

console.log(formatIdentifier(123)); // "User #123"
console.log(formatIdentifier('alice')); // "@alice"

Exercise 2: Literal Type for Days

Create a type for days of the week and a function that tells if a day is a weekend:

Solution
type DayOfWeek = 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';

function isWeekend(day: DayOfWeek): boolean {
  return day === 'Saturday' || day === 'Sunday';
}

console.log(isWeekend('Monday')); // false
console.log(isWeekend('Saturday')); // true
console.log(isWeekend('Someday')); // Error! Not a valid day

Exercise 3: Discriminated Union for Payments

Create a discriminated union for different payment methods:

Solution
type Payment =
  | { method: 'credit_card'; cardNumber: string; cvv: string }
  | { method: 'paypal'; email: string }
  | { method: 'bank_transfer'; accountNumber: string; routingNumber: string };

function processPayment(payment: Payment): string {
  switch (payment.method) {
    case 'credit_card':
      return `Processing card ending in ${payment.cardNumber.slice(-4)}`;
    case 'paypal':
      return `Processing PayPal payment for ${payment.email}`;
    case 'bank_transfer':
      return `Processing bank transfer to account ${payment.accountNumber}`;
  }
}

console.log(
  processPayment({
    method: 'credit_card',
    cardNumber: '1234567890123456',
    cvv: '123',
  })
);
// "Processing card ending in 3456"

Exercise 4: Nullable Search Result

Create a function that searches an array and returns the item or null:

Solution
type SearchResult = string | null;

function findItem(items: string[], query: string): SearchResult {
  for (const item of items) {
    if (item.toLowerCase().includes(query.toLowerCase())) {
      return item;
    }
  }
  return null;
}

const fruits = ['Apple', 'Banana', 'Cherry', 'Date'];

const result = findItem(fruits, 'ban');
if (result !== null) {
  console.log(`Found: ${result}`);
} else {
  console.log('Not found');
}

Key Takeaways

  1. Union types use |: Combine types with the pipe symbol to allow multiple options
  2. Literal types restrict values: Use exact strings or numbers as types
  3. Type narrowing is automatic: TypeScript tracks typeof, truthiness, and equality checks
  4. Discriminated unions are powerful: Add a common literal property to distinguish object variants
  5. Unions with null/undefined: The most common pattern for optional or nullable values
  6. Literal unions replace enums: Often cleaner than traditional enum declarations

Resources

Resource Type Description
TypeScript Handbook: Union Types Documentation Official union types guide
TypeScript Handbook: Narrowing Documentation Deep dive into type narrowing
TypeScript Handbook: Literal Types Documentation Official literal types guide

Next Lesson

Now that you can create flexible types with unions and precise types with literals, let us learn how to describe complex object structures using interfaces.

Continue to Lesson 3.4: Interfaces