CodeFixesHub
    programming tutorial

    Typing Functions with Multiple Return Types (Union Types Revisited)

    Master functions that return multiple types in TypeScript. Learn safe patterns, examples, runtime checks, and optimization tips—read the practical guide now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 2
    Published
    21
    Min Read
    2K
    Words
    article summary

    Master functions that return multiple types in TypeScript. Learn safe patterns, examples, runtime checks, and optimization tips—read the practical guide now.

    Typing Functions with Multiple Return Types (Union Types Revisited)

    Introduction

    Functions that can return multiple shapes of data are common in real-world TypeScript code. You may write a parser that returns either a Result or an Error, a cache lookup that returns the cached item or null, or a validation function that returns a value or a descriptive failure object. Typing these functions correctly is critical: you want ergonomics for consumers, safety at compile time, and predictable runtime behavior.

    In this article you'll learn how to model functions with multiple return types (union returns) in TypeScript—beyond the naive union annotation. We'll cover static typing patterns, narrowing strategies, runtime guards, discriminated unions, performance and developer experience trade-offs, and migration steps for legacy code. You'll get practical, intermediate-to-advanced examples with step-by-step instructions so you can apply patterns in your own codebase.

    Specifically, we'll explore discriminated unions, type predicates and custom guards, shape-based narrowing, tagged wrappers, error-first patterns, and how compiler flags and safer indexing can affect your designs. We'll also highlight pitfalls like excessive use of any or unsafe assertions and show safer alternatives. Where useful, we'll link to deeper resources so you can expand on topics like writing custom type guards or hardening code against mis-typed inputs.

    By the end of this guide you will be able to:

    • Choose the right union-return pattern for your use case
    • Write reliable narrowing logic so callers get correct inferred types
    • Avoid common runtime and maintenance issues
    • Use TypeScript features and tooling to balance safety and ergonomics

    Background & Context

    TypeScript's union types allow a single value to be one of several types. When a function's return type is a union, the caller needs a way to narrow the union before using properties specific to one branch. Without appropriate narrowing, developers rely on type assertions or runtime checks, which can erode safety.

    There are many patterns to handle union return values: plain unions (A | B), discriminated unions (using a common tag), wrappers like Result<T, E>, tagged tuples, and using type predicates for custom guards. Each has trade-offs in ergonomics (how easy it is to use), extensibility, and runtime cost. For instance, discriminated unions are ergonomic and compiler-friendly, while runtime-only checks without predicates can be brittle.

    This topic is important because improper handling of union returns can lead to subtle runtime errors, confusing APIs, and maintenance headaches. We will focus on patterns that maximize static guarantees while remaining practical for intermediate developers working in apps or libraries.

    Key Takeaways

    • Use discriminated unions for clear, ergonomic APIs and compiler-friendly narrowing.
    • Implement custom type predicates for complex runtime shapes and better DX—see guides on using custom guards.
    • Avoid overusing any and unchecked assertions; they introduce runtime risk and security issues.
    • Consider a Result<T, E> wrapper for explicit success/failure handling.
    • Use compiler flags and safer indexing to surface edge cases early.
    • Prefer compile-time guarantees when possible, and pair runtime validation with typed schemas.

    Prerequisites & Setup

    To follow the examples, you should have:

    • TypeScript 4.x or newer installed (some narrowing improvements land in newer versions)
    • Basic knowledge of TypeScript unions, generics, and type guards
    • Node.js and a code editor with TypeScript support (VS Code recommended)

    Optional tooling and libraries that help in practice:

    • A runtime validation library like zod or io-ts for schema-driven checks
    • ESLint + TypeScript plugin for consistent patterns
    • Familiarity with writing JSDoc can help when typing JavaScript files; see our guide on using JSDoc for type checking JavaScript files

    Now let's deep-dive into concrete patterns you can use across different scenarios.

    Main Tutorial Sections

    1) Plain Union Returns: When to use A | B

    A straightforward way to express a function that returns either type A or B is using a union: function f(): A | B { ... }. This is appropriate for simple cases where callers can check a common property or when using structural narrowing (e.g., checking for presence of a property).

    Example:

    ts
    type Success = { data: string };
    type Failure = { error: string };
    
    function fetchSimple(): Success | Failure {
      if (Math.random() > 0.5) return { data: 'ok' };
      return { error: 'not ok' };
    }
    
    const r = fetchSimple();
    if ('data' in r) {
      // r is inferred as Success
      console.log(r.data);
    } else {
      console.log(r.error);
    }

    Step-by-step: choose plain union if branches are structurally distinct and callers can reliably test for a discriminating property. If structural overlap exists, prefer a stronger pattern.

    (For deeper troubleshooting and puzzles about tricky type behavior, see our article on solving TypeScript type challenges and puzzles.)

    2) Discriminated Unions: The Most Robust Pattern

    Discriminated unions require a shared literal tag property (e.g., status or type). The compiler performs perfect narrowing on the tag, making usage ergonomic and safe.

    Example:

    ts
    type Ok<T> = { status: 'ok'; value: T };
    type Err = { status: 'error'; message: string };
    
    type Result<T> = Ok<T> | Err;
    
    function parse(input: string): Result<number> {
      const n = Number(input);
      if (Number.isFinite(n)) return { status: 'ok', value: n };
      return { status: 'error', message: 'invalid number' };
    }
    
    const parsed = parse('42');
    if (parsed.status === 'ok') {
      // inferred as Ok<number>
      console.log(parsed.value + 1);
    } else {
      console.error(parsed.message);
    }

    Step-by-step: design APIs with a stable tag field early. This pattern is friendly for both TypeScript typing and JSON interchange.

    3) Result<T, E> Wrapper: Explicit Success/Failure Handling

    Inspired by Rust's Result, encapsulating success and error in a single generic structure encourages explicit handling and composition.

    Example:

    ts
    type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
    
    function getFromCache<T>(key: string): Result<T, 'miss' | 'stale'> {
      const item = null; // pretend cache miss
      return { ok: false, error: 'miss' };
    }
    
    const hit = getFromCache<number>('x');
    if (hit.ok) {
      // safe to use hit.value
    } else {
      // handle hit.error
    }

    Step-by-step: Use Result for flows with explicit success/failure, where exceptions are not used. It improves readability and testability.

    (See a practical example of typing data-fetching hooks that use similar patterns in our case study: Typing a Data Fetching Hook.)

    4) Type Predicates & Custom Guards for Complex Narrowing

    When runtime shapes are complex, write a custom type predicate (function returning x is T) to help the compiler narrow. Custom guards are useful when checking deep or optional properties.

    Example:

    ts
    type User = { id: string; profile?: { bio?: string } };
    
    declare function isUser(obj: any): obj is User;
    
    function maybeUser(input: unknown): User | string {
      if (typeof input === 'object' && input !== null && 'id' in input) {
        return input as User; // naive
      }
      return 'not user';
    }
    
    // Better: implement isUser properly and use it

    Step-by-step: implement robust runtime checks in a dedicated guard function and annotate it with a type predicate. This centralizes validation logic and improves DX.

    If you need help writing guards and predicates, see our practical guide on using type predicates for custom type guards.

    5) Tagging at Runtime vs Compile-Time: Bridging the Gap

    Sometimes the code receives data from external sources (APIs, JSON). You should validate and tag runtime data before exposing it with complex types. Use runtime validation libraries or custom checks to produce a strongly typed wrapper.

    Example using a pseudo-schema:

    ts
    // runtime validate and wrap
    function validateUserPayload(payload: unknown): Result<User, string> {
      // run validations here; if pass:
      return { ok: true, value: payload as User };
    }

    Step-by-step: validate early (input boundary) and transform untrusted input into validated, tagged structures. This reduces downstream type assertions.

    For patterns on typing configuration and runtime schemas see Typing Environment Variables and Configuration in TypeScript.

    6) Narrowing with in, typeof, Array.isArray, and Property Checks

    TypeScript supports several narrowing techniques. Use the most specific and safest check to give the compiler confidence.

    Example:

    ts
    function handle(x: string | string[] | null) {
      if (x === null) return;
      if (Array.isArray(x)) {
        // x is string[]
      } else {
        // x is string
      }
    }

    Step-by-step: prefer builtin narrowing constructs. Avoid fragile checks that rely on implementation details. When dealing with indexed access or arrays, enable stricter flags to catch mistakes early (next section).

    See strategies to protect indexing and property access in our guide on safer indexing with noUncheckedIndexedAccess.

    7) Avoiding any and Unsafe Assertions

    When narrowing gets complicated, developers sometimes reach for any or use aggressive type assertions (as T). While these silence the compiler, they remove safety. Instead, use narrower assertions, validation, or encapsulation.

    Example (bad):

    ts
    function parseAny(obj: any): Config { return obj as Config; }

    Better: validate or build Config explicitly, or use a runtime schema to cast safely. Refer to common security concerns around any and assertions to justify more robust approaches.

    For more on why any and assertions are risky and how to mitigate issues, read security implications of using any and type assertions in TypeScript.

    8) Using JSDoc and JavaScript Boundaries

    If you maintain a mixed JS/TS repo or expose JS APIs, JSDoc-based typing can help consumers get IDE feedback without migrating everything to .ts.

    Example in JS with JSDoc:

    js
    /**
     * @param {unknown} input
     * @returns {{status: 'ok', value: number} | {status: 'error', message: string}}
     */
    function parse(input) {
      // runtime checks
    }

    Step-by-step: annotate public JS functions with return unions in JSDoc so consumers get proper hints. For more on using JSDoc effectively, see using JSDoc for type checking JavaScript files.

    9) Composition Patterns: map/flatMap/andThen for Union Returns

    When your code frequently composes operations that return union-like results, add helper combinators (map/flatMap) to simplify chaining without repeated narrowing.

    Example utilities for Result:

    ts
    function map<T, U, E>(r: Result<T, E>, fn: (t: T) => U): Result<U, E> {
      return r.ok ? { ok: true, value: fn(r.value) } : r;
    }
    
    function andThen<T, U, E>(r: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> {
      return r.ok ? fn(r.value) : r;
    }

    Step-by-step: expose compositors on your wrapper types to keep call sites clean. This pattern reduces ad-hoc checks and centralizes behavior.

    (You can find similar composition patterns used in typed hooks and libraries in our practical case study on typing a data fetching hook.)

    Advanced Techniques

    Once you are comfortable with the basics, several advanced techniques help scale union-return patterns in larger codebases.

    • Schema-driven validation: combine runtime validators (zod/io-ts) with typed factories so the runtime shape and the TS types stay in sync. This eliminates many manual guards.
    • Exhaustiveness checks: use never-checks in switch statements to ensure future-proof discriminated unions.
    ts
    function assertNever(x: never): never { throw new Error('Unexpected: ' + String(x)); }
    
    switch (u.type) {
      case 'a': break;
      case 'b': break;
      default: assertNever(u);
    }
    • Type inference helpers: use generics to propagate types and reduce repetition in wrapper types (Result<T, E> as a canonical example).
    • Compiler flags: enable strict mode and consider flags like noUncheckedIndexedAccess and strictNullChecks to expose hidden assumptions sooner. For a guide on advanced flags and their impact, check advanced TypeScript compiler flags and their impact.

    Performance tips: avoid heavy runtime validation on tight loops; validate at boundaries and trust internal invariants. Memoize parsing results or use structural hashing for repeated checks.

    Best Practices & Common Pitfalls

    Dos:

    • Prefer discriminated unions for public APIs.
    • Validate external inputs once at the boundary, then expose typed internals.
    • Use custom type guards to centralize complex checks.
    • Keep error shapes consistent and small for easy handling.

    Don'ts:

    • Don’t scatter ad-hoc type assertions across code; they are hard to audit.
    • Don’t expose weakly typed APIs (any) unless intentionally internal and audited.
    • Avoid ambiguous unions where both branches share all properties—this makes narrowing unreliable.

    Common pitfalls & troubleshooting:

    • Overlapping shapes: if union members share properties, add a dedicated discriminant or wrap results to avoid confusion.
    • Ignoring undefined/null: enable strictNullChecks to avoid runtime surprises and adjust signatures accordingly.
    • Indexing into possibly undefined arrays or objects; turning on safer indexing with noUncheckedIndexedAccess helps catch these.

    Also be cautious of performance costs if you run deep validators for every call; prefer boundary validation.

    Real-World Applications

    • API clients: return typed success/error shapes from fetch wrappers. Use discriminated unions for predictable handling in UI code.
    • Parsers: return Result<T, ParseError> so call sites can handle recoverable errors without exceptions.
    • Libraries: expose a Result or tagged union as the public return type to force consumers to handle both success and failure.
    • Config loaders: when reading configuration from env or files, validate and return either a typed config or a diagnostics object—see typing environment variables and configuration in TypeScript for patterns.

    For example, a typed config loader might return { ok: true, value: Config } | { ok: false, errors: string[] } so consumers can decide whether to proceed or abort.

    Conclusion & Next Steps

    Typing functions that return multiple types requires balancing ergonomics and safety. Start with discriminated unions and Result wrappers for clarity, use custom type predicates for complex shapes, validate at boundaries, and avoid unsafe any/assertions. Enable strict compiler flags and adopt composition helpers to keep call sites tidy.

    Next steps: practice the patterns in an existing small module, migrate one function at a time, and add tests and runtime validation. If you want to extend these patterns into state or form libraries, check our practical case studies on typing a state management module and typing a form management library.

    Enhanced FAQ

    Q1: When should I prefer a discriminated union over a plain A | B union? A1: Prefer discriminated unions when you control the data shape and want ergonomic narrowing for callers. If you can add a literal tag, the compiler will give precise narrowing without custom guards. Plain unions are acceptable for simple, clearly distinguishable shapes, but tags reduce ambiguity.

    Q2: How do I write a type predicate for a nested shape? A2: Implement a runtime check that verifies the presence and types of required nested properties, then annotate the function as a type predicate: function isFoo(x: any): x is Foo { return typeof x === 'object' && x !== null && typeof (x as any).bar === 'string'; } Keep checks centralized and test them.

    Q3: Is Result<T, E> better than throwing exceptions? A3: It depends. Result promotes explicit handling and is ideal for recoverable errors and functional composition. Exceptions are simpler for truly exceptional cases. Consider your domain: libraries often prefer Result to avoid surprise throws; apps may use exceptions for irrecoverable states.

    Q4: How can I keep runtime validation DRY while keeping TypeScript types accurate? A4: Use schema libraries (zod, io-ts) that generate runtime validators and provide inferred TypeScript types. Alternatively, keep a single source of truth where runtime checks live in a small factory that returns typed values.

    Q5: How do I handle union returns in JavaScript files? A5: Annotate return types with JSDoc and implement runtime checks. JSDoc gives IDEs type hints without migrating to TypeScript. See our guide on using JSDoc for type checking JavaScript files.

    Q6: What are common mistakes that cause type narrowing to fail? A6: Common issues include: overlapping shapes without a discriminant, relying on properties added at runtime, using any/assertions that hide real types, and not enabling strictNullChecks. Ensuring distinct tags or wrapper shapes resolves many failures.

    Q7: How do compiler flags affect union returns? A7: Flags like strictNullChecks and noUncheckedIndexedAccess make TypeScript surface nullable/undefined access and indexing bugs early. Enabling strict mode encourages explicit handling of edge cases and leads to safer union handling. See advanced TypeScript compiler flags and their impact for more.

    Q8: Can I mix runtime schema validation and discriminated unions? A8: Yes. Validate runtime data and then map it into discriminated union shapes before returning it from your API boundary. This approach gives you runtime safety and compile-time ergonomics.

    Q9: How do I test custom type guards? A9: Write unit tests that exercise both positive and negative cases for each guard, ensuring they return true for valid shapes and false for invalid ones. Also test integration scenarios where guards are used in flow control.

    Q10: Any recommendations for migrating legacy code that uses many assertions? A10: Start incrementally: pick a module, introduce a Result wrapper or discriminated union for a few public functions, add tests and runtime validation for inputs, and remove assertions gradually. Use linters to flag new assertions and prefer centralized guards.

    Further reading and related resources:

    If you want, I can convert one of your functions to use a discriminated union or Result wrapper and show a step-by-step migration. Which function or module would you like to start with?

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:20:03 PM
    Next sync: 60s
    Loading CodeFixesHub...