TypeScript Basics #4: Union / Literal / Narrowing

5 min read

Last time we covered how to name and reuse object types. This time we cover the tools for expressing “one of several possibilities” — union types, literal types, and narrowing. The three work together and show TypeScript’s true expressive power.

Union types — one of several possibilities #

Used when a value could be one of two or three types.

union type
let value: string | number = '안녕';
value = 42;          // ✓
value = true;        // 🚫 error: boolean isn't allowed

string | number means “this value is either a string or a number.” Separate the possible types with the pipe symbol (|).

Common union patterns #

null or an object:

value or null
let user: User | null = null;
user = { id: 'u-1', name: '철수' };

Accepting multiple input forms:

Accepting different inputs
function parseId(id: string | number): string {
  return String(id);
}

parseId(42);      // ✓
parseId('u-1');   // ✓

Success or error:

Result type
type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };

function fetchData(): Result {
  // ...
  return { ok: true, value: '응답 데이터' };
}

The last pattern is called a discriminated union (or tagged union) and is very widely used (more on it below).

Literal types — expressing exact values #

TypeScript can express not only broad types like “string” but also the exact value “hello” as a type.

literal type
let direction: 'left' | 'right' | 'up' | 'down' = 'left';
direction = 'right';     // ✓
direction = 'forward';   // 🚫 error: only one of the four is allowed

'left' | 'right' | 'up' | 'down' is a subset of string that allows exactly one of those four values. This is the technique we mentioned in #2 as the lighter alternative to enum.

Number literals work too:

number literal
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
const roll: DiceRoll = 4;     // ✓
const fail: DiceRoll = 7;     // 🚫

The power of literal + union #

Combining literals with unions lets you express precise types that JavaScript can’t.

UI state
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type Size = 'sm' | 'md' | 'lg';

function Button(props: { variant: ButtonVariant; size: Size; label: string }) {
  // ...
}

Button({ variant: 'primary', size: 'md', label: '확인' });   // ✓
Button({ variant: 'huge', size: 'md', label: '확인' });      // 🚫 'huge' not allowed

It’s a good fit anywhere the options form a finite set — API response statuses, component variants, loading states.

const assertion — automatically making literal types #

Add as const when creating an object or array, and every value is inferred as a literal type.

as const
const config = {
  mode: 'production',
  retries: 3,
} as const;

// config.mode: 'production' (the literal 'production', not string)
// config.retries: 3 (the literal 3, not number)

config.mode = 'development';  // 🚫 readonly + literal — can't change

Values created with as const are pinned to readonly + the narrowest literal type. This is great for configuration objects that shouldn’t change.

It’s also commonly used with arrays.

Array with as const
const colors = ['red', 'green', 'blue'] as const;
// colors: readonly ['red', 'green', 'blue']

type Color = typeof colors[number];
// Color = 'red' | 'green' | 'blue'

typeof colors[number] means “the union of every element type of the colors array.” This pattern of putting data in one place and deriving the type from it automatically is used heavily in real codebases. The burden of keeping data and types in sync disappears.

Narrowing — refining the type inside branches #

When you receive a union-typed value, multiple types are possible, so you can’t immediately call methods that belong to only one of them.

union limitation
function process(value: string | number): string {
  return value.toUpperCase();  // 🚫 number doesn't have toUpperCase
}

The solution is narrowing. Through conditionals or checks, you lead TypeScript to infer a narrower type inside each branch.

Narrowing with typeof — primitives #

typeof narrowing
function process(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();    // value is narrowed to string here
  }
  return value.toFixed(2);          // narrowed to number here
}

typeof is a regular JavaScript keyword, but TypeScript recognizes it and automatically narrows the type inside that branch.

Narrowing with the ‘in’ operator — objects #

in narrowing
type Cat = { meow: () => void };
type Dog = { bark: () => void };

function makeSound(animal: Cat | Dog): void {
  if ('meow' in animal) {
    animal.meow();    // narrowed to Cat
  } else {
    animal.bark();    // narrowed to Dog
  }
}

The in keyword checks whether an object has a specific property. If it does, TypeScript narrows the type to the variant that has that property.

Narrowing with instanceof — classes #

instanceof narrowing
function logError(error: Error | string): void {
  if (error instanceof Error) {
    console.log(error.message);     // narrowed to Error
    console.log(error.stack);
  } else {
    console.log(error);              // string
  }
}

Discriminated Union — distinguish by tag #

This is the most powerful and common pattern for object unions. A shared “tag” field distinguishes which variant is in play.

discriminated union
type Loading = { status: 'loading' };
type Success = { status: 'success'; data: string };
type Failure = { status: 'failure'; error: string };

type State = Loading | Success | Failure;

function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return '로딩 중...';
    case 'success':
      return state.data;        // narrowed to Success — data is accessible
    case 'failure':
      return state.error;        // narrowed to Failure
  }
}

The value of the status field tells you which state it is. Inside each branch of the switch the type narrows automatically, and the field for that branch (data, error) is safely accessible.

This pattern applies anywhere multiple shapes are mixed — async state, form state, message kinds. Worth memorizing.

User-defined type guards #

Complex checks can be extracted into a reusable function.

type guard function
type Cat = { type: 'cat'; meow: () => void };
type Dog = { type: 'dog'; bark: () => void };

function isCat(animal: Cat | Dog): animal is Cat {
  return animal.type === 'cat';
}

function makeSound(animal: Cat | Dog): void {
  if (isCat(animal)) {
    animal.meow();    // narrowed to Cat
  } else {
    animal.bark();
  }
}

The animal is Cat part is the key — it tells the compiler “if this function returns true, the argument is of type Cat.” This is called a type predicate.

It’s great for reuse because you can extract complex check logic into one place. You do take responsibility for writing the check correctly though — the compiler cannot verify that the function body actually guarantees the type.

Truthiness narrowing #

JavaScript’s truthy/falsy checks also produce narrowing.

null check
function greet(name: string | null): string {
  if (name) {
    return `안녕, ${name.toUpperCase()}님`;   // narrowed to string
  }
  return '이름이 없습니다';
}
Empty array check
function first<T>(arr: T[] | undefined): T | undefined {
  if (arr && arr.length > 0) {
    return arr[0];     // narrowed to T[]
  }
  return undefined;
}

These simple if checks naturally act as narrowing.

Exhaustiveness checks with never #

You can have the compiler ensure that every case of a discriminated union is handled.

exhaustiveness check
type State = Loading | Success | Failure;

function render(state: State): string {
  switch (state.status) {
    case 'loading':
      return '로딩 중...';
    case 'success':
      return state.data;
    case 'failure':
      return state.error;
    default:
      const _exhaustive: never = state;  // unreachable if every case is handled
      return _exhaustive;
  }
}

If a new variant is added to State later but the switch isn’t updated, the type at the default branch will no longer be never — it will be the new type — producing a compile error. It’s a powerful pattern for catching omissions at compile time.

It can feel awkward at first, but it shines in large codebases. Don’t bother memorizing it — just know that this is possible.

Common nullable patterns #

The shapes T | null and T | undefined show up so often that many teams give them aliases.

nullable aliases
type Nullable<T> = T | null;
type Optional<T> = T | undefined;

let user: Nullable<User> = null;
let result: Optional<string> = undefined;

Nullable<T> is a preview of the generics we cover in #6. Any type can fill the T slot.

Try it yourself — async state #

Let’s model a pattern that shows up often in React or general async code.

async-state.ts
// All possible states of a data fetch
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

// Simulating user-info fetching
type User = { id: string; name: string };

let state: FetchState<User> = { status: 'idle' };

function startFetch(): void {
  state = { status: 'loading' };
}

function onSuccess(user: User): void {
  state = { status: 'success', data: user };
}

function onError(message: string): void {
  state = { status: 'error', error: message };
}

function describeState(s: FetchState<User>): string {
  switch (s.status) {
    case 'idle':
      return '아직 시작 안 함';
    case 'loading':
      return '불러오는 중...';
    case 'success':
      return `안녕하세요, ${s.data.name}님!`;
    case 'error':
      return `에러: ${s.error}`;
  }
}

startFetch();
console.log(describeState(state));  // 불러오는 중...

onSuccess({ id: 'u-1', name: '철수' });
console.log(describeState(state));  // 안녕하세요, 철수님!

In each state, only the fields meaningful for that state are accessible. There’s no data or error in idle or loading; only data in success; only error in error. Wrong field access is blocked at compile time — that’s the real power of discriminated unions.

Common pitfalls #

1. Narrowing widens again #

when narrowing breaks
function process(items: (string | number)[]): void {
  items.forEach(item => {
    if (typeof item === 'string') {
      // narrowed to string here
      setTimeout(() => {
        item.toUpperCase();   // 🚫 inside the callback it may widen back to string | number
      });
    }
  });
}

Moving inside a callback or closure can break the narrowing. You can work around this by storing the narrowed value in a local variable.

Workaround
if (typeof item === 'string') {
  const s = item;            // hold the narrowed result in a variable
  setTimeout(() => {
    s.toUpperCase();         // ✓
  });
}

2. Forgetting quotes when writing literal unions #

Mistake
type Color = 'red' | 'green' | blue;  // 🚫 not 'blue' — interpreted as an identifier

A literal is the value inside the quotes. Without quotes it’s interpreted as an identifier (a variable reference), which means something completely different. A common early mistake.

3. Overly narrow literal types #

Excessive literal
function setMode(mode: 'dev' | 'prod'): void { /* ... */ }

const mode = 'dev';      // inferred as 'dev' literal? or string?
setMode(mode);           // can error in some cases

const mode = 'dev' is inferred as the literal type 'dev', but let mode = 'dev' is inferred as string. When the distinction matters, use as const or an explicit annotation to make your intent clear.

Wrap-up #

This post covered the tools for “one of several possibilities.”

  • union (A | B) — one of several types
  • literal type ('red' | 'blue') — exact values as types
  • as const — automatically literal + readonly
  • narrowing — refine the type inside a branch with typeof, in, instanceof, discriminated unions, and user-defined type guards
  • discriminated union — the standard pattern for object unions (a tag field like status + switch)
  • never — exhaustiveness checks

In the next post, “TypeScript Basics #5: Function Types,” we cover how to express function types more precisely — optional/default parameters, function overloads, and an introduction to generics. Generics get deeper coverage in #6, so #5 is just a light first encounter.

X