C
TypeScript/Async/Lesson 07

Advanced Error Handling — Discriminated Union + Result<T, E> Pattern

40 min·theory
This chapter
7/7
TypeScript

Advanced Error Handling — Discriminated Union + Result Pattern

💡 Why Should You Learn This? — Treating Errors as 'Values' Is Safer

🎯 throw/catch causes control flow to jump — callers cannot tell from the signature alone which errors they need to catch.
💼 The Result pattern treats errors as **return values**. The possible error types are embedded in the function signature, so omissions surface at compile time.
Modeling with a Discriminated Union (`type Result = Ok | Err`) lets the call site branch naturally with `if (result.ok)`.
🔗 The same mindset as Rust, Go, and Kotlin Result/Either patterns — a standard tool of functional programming.
📈 The `never` type lets the compiler verify an 'exhaustive check' (that every case has been handled).
🏢 실무에서는
If you type the return of a Next.js Server Action as `Promise>`, the client component is forced to handle both the success and failure branches without omission. When errors flow via throw, the client has to wonder 'where exactly should I have wrapped this in try/catch?' — but with a Result return, the compiler does that worrying for you.

Discriminated Union · Result · Exhaustive Check

1. Discriminated Union — Types Narrowed by a Tag Field

ts
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  if (s.kind === 'circle') return Math.PI * s.radius ** 2; // s is narrowed to circle branch
  if (s.kind === 'square') return s.side ** 2;
  // If execution reaches here, TS will be suspicious (next item)
}

Narrowing the tag (kind) field value narrows the entire shape of the object.

2. Result Pattern

ts
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

function parsePositive(s: string): Result<number, string> {
  const n = Number(s);
  if (Number.isNaN(n)) return { ok: false, error: 'Not a number' };
  if (n < 0) return { ok: false, error: 'Negative numbers not allowed' };
  return { ok: true, value: n };
}

const r = parsePositive('-3');
if (r.ok) console.log(r.value);  // r is narrowed to Ok<number>
else console.log(r.error);       // r is narrowed to Err<string>

If the caller forgets to handle an error, the compiler blocks compilation — accessing r.value directly without an if check is rejected.

3. Exhaustive Check with never

ts
type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keypress'; key: string }
  | { kind: 'scroll'; offset: number };

function handle(e: Event): string {
  switch (e.kind) {
    case 'click': return `Click (${e.x},${e.y})`;
    case 'keypress': return `Key: ${e.key}`;
    case 'scroll': return `Scroll: ${e.offset}`;
    default:
      const _exhaustive: never = e; // If a new kind is added, a compile error occurs here
      return _exhaustive;
  }
}

If you add a new variant to Event but forget to add a case in the switch, e is no longer never, so the _exhaustive: never assignment fails to compile — you catch the missing handler at compile time.

4. throw vs Result — When to Use Which?

SituationthrowResult
A bug that should never happen
Anticipated user input errors
Handling external API responses
Emergency escape from deeply nested calls

throw is for 'abnormal' conditions; Result is for 'expected failures'.

💻 🅰️ JS Approach — Error Branching with throw/catch Only
// ❌ JS — The type of throw is not embedded in the signature

function parsePositive(s) {
  const n = Number(s);
  if (Number.isNaN(n)) throw new Error('Not a number');
  if (n < 0) throw new Error('Negative numbers not allowed');
  return n;
}

// The caller needs to look at the function body to know what errors are possible
function main() {
  try {
    const n = parsePositive('-3');
    console.log('Success:', n);
  } catch (err) {
    console.log('Failure:', err.message);
    // To distinguish error types, compare err.message strings? — Fragile
    if (err.message === 'Not a number') console.log('Guide for number input');
    else if (err.message === 'Negative numbers not allowed') console.log('Guide for positive number input');
  }
}
main();

// What if the user forgets try/catch? — Explodes at runtime
💻 🅱️ TS Approach — Result + Discriminated Union + Exhaustive Check
// ✅ TS — Errors as values with the Result pattern

// 1. Define Result
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });

// 2. Error types as Discriminated Union
type ParseError =
  | { kind: 'notNumber'; input: string }
  | { kind: 'negative'; value: number };

function parsePositive(s: string): Result<number, ParseError> {
  const n = Number(s);
  if (Number.isNaN(n)) return err({ kind: 'notNumber', input: s });
  if (n < 0) return err({ kind: 'negative', value: n });
  return ok(n);
}

// 3. Caller — exhaustive check
function handle(input: string): string {
  const r = parsePositive(input);

  if (r.ok) {
    return `Success: ${r.value}`; // r is narrowed to Ok<number>
  }

  // r is narrowed to Err<ParseError> here
  switch (r.error.kind) {
    case 'notNumber':
      return `Not a number: "${r.error.input}"`;
    case 'negative':
      return `Negative: ${r.error.value} — positive only`;
    default: {
      const _exhaustive: never = r.error;
      return _exhaustive;
    }
  }
}

console.log(handle('42'));   // Success: 42
console.log(handle('-3'));   // Negative: -3 — positive only
console.log(handle('abc'));  // Not a number: "abc"

// If a new type is added to ParseError
// → If no branch is added to switch, compile error (cannot assign to never)
// → Missing handler found at compile time

💡 💡 Result Pattern Practical Guide

1. Build Result without a library — 30 lines is all you need

ts
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok  = <T>(v: T): Result<T, never> => ({ ok: true, value: v });
const err = <E>(e: E): Result<never, E> => ({ ok: false, error: e });

2. Classify errors with Discriminated Union as well

ts
type FormError =
  | { kind: 'empty'; field: string }
  | { kind: 'tooLong'; field: string; max: number };

Attach the exact additional information (field · max) to the right variant for each error kind.

3. Force exhaustive check with never

ts
default: const _: never = errValue; // Compile error if handler is missing

4. The boundary between Result and throw
Predictable failures (validation failure · 404 · authentication) → Result. Unpredictable bugs (null pointer · OOM) → throw + global handler.

5. async + Result

ts
async function getUser(id: number): Promise<Result<User, ApiError>> {
  try {
    const user = await fetchJson<User>(`/api/users/${id}`);
    return ok(user);
  } catch (e) {
    if (e instanceof ApiError) return err(e);
    throw e; // Re-throw unexpected errors
  }
}

Expected failures go through Result; unexpected errors are re-thrown.

⚡ Try It Yourself — Result Pattern

Get a feel for the flow of handling errors as return values.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Quick Quiz

Why do we put the `default: const _: never = x;` pattern inside a Discriminated Union?
💡 Once all cases in the switch are handled, the type of `x` narrows to `never` by the time the `default` branch is reached. If a new variant is added to the union but its corresponding branch is not added to the switch, `x` becomes that new variant type rather than `never`, and the `_: never = x` assignment produces a compile error. This is the pattern that lets the compiler catch missing cases.
Advanced Error Handling — Result<T, E> Pattern - TypeScript