CodeFixesHub
    programming tutorial

    Typing Arrays of Mixed Types (Union Types Revisited)

    Master typing for mixed-type arrays with practical examples, type guards, and best practices. Improve safety and DX — learn step-by-step and apply today.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 2
    Published
    22
    Min Read
    3K
    Words
    article summary

    Master typing for mixed-type arrays with practical examples, type guards, and best practices. Improve safety and DX — learn step-by-step and apply today.

    Typing Arrays of Mixed Types (Union Types Revisited)

    Introduction

    Arrays that hold mixed types are common in real code. You might receive JSON where an array contains strings and numbers, or you might combine different domain objects into a single collection for processing. TypeScript's union types and tuples give you tools to model these shapes, but naive approaches introduce pain: brittle runtime behavior, poor editor support, and subtle bugs when narrowing fails.

    In this in-depth tutorial for intermediate developers we revisit union types applied to arrays. You will learn how to express mixed-type arrays safely, how to narrow them reliably at runtime, when to prefer discriminated unions or tuples, and how to use advanced mapped and conditional types to transform and validate arrays. We cover ergonomics for APIs, performance considerations, and common pitfalls that cause silent runtime errors.

    By the end of this article you will be able to choose the right type for mixed arrays, write robust type guards, apply compile time constraints to array operations, and integrate runtime validation when necessary. Expect many practical code examples, step-by-step patterns for migration, links to deeper topics like custom type guards and safer indexing, and a thorough FAQ addressing real-world concerns.

    Background & Context

    Union types let a value be one of several alternatives. For arrays the simplest approach is an array of unions, for example (string | number)[]. That models each element as either string or number. But array-level constraints are often more nuanced: you may need a tuple of specific alternating types, or an array where certain sequences are guaranteed, or an array of objects discriminated by a tag field.

    Why does this matter? Precise types improve safety and developer experience. Editors can give better autocompletion, refactors are safer, and runtime checks become localized and explicit. Mis-typing an array can produce runtime errors when code assumes a property exists on every element. In large codebases these issues compound. We will walk from the basic to advanced patterns and show how to avoid common traps.

    Key Takeaways

    • Understand the difference between array of unions and union of tuples
    • Use discriminated unions for structured mixed arrays
    • Write custom type guards and type predicates to narrow elements safely
    • Prefer tuples when element positions and types matter
    • Use mapped and conditional types to transform heterogeneous arrays
    • Apply runtime validation when data originates from untyped sources
    • Avoid unsafe type assertions and learn safer migration strategies

    Prerequisites & Setup

    This article assumes intermediate familiarity with TypeScript fundamentals: union types, tuples, type aliases, interfaces, generics, and basic conditional types. You should have TypeScript 4.x or later installed. Examples use modern language features like variadic tuple inference and template literal types, so prefer TypeScript 4.5+ for the best experience.

    To follow along locally create a small project with tsconfig set to strict true. Also have a modern editor like VS Code configured for TypeScript intellisense. When working with JavaScript projects you can use JSDoc annotations to get similar checking; see our guide on Using JSDoc for type checking JavaScript files for patterns if you need that path.

    Main Tutorial Sections

    1. Arrays of Unions: Simple and Common

    The most straightforward shape is array of unions. Example:

    ts
    type Value = string | number | boolean;
    const arr: Value[] = ['hello', 42, true, 'world'];

    This type says each element is independently one of the alternatives. It works well when you treat elements homogeneously with runtime checks. But it loses positional meaning. If your code expects the first element to be a string and the second to be a number, a tuple is a better fit. Also remember that when you index arr[i] the type is Value, so you will need narrowing before calling string methods or numeric operations.

    When elements come from external sources such as APIs or configuration, prefer runtime validation or explicit narrowing before use. For patterns where behavior changes by element type, custom type guards become essential; see the section on type guards below for examples and links to deeper guidance.

    2. Narrowing Elements with Type Guards and Predicates

    Narrowing is central to safely working with union types. TypeScript uses control-flow analysis but needs help when you have complex predicates. Write custom type guards using type predicates to teach TypeScript how to refine an element's type.

    Example type guard:

    ts
    type Item = { type: 'a'; a: string } | { type: 'b'; b: number } | string;
    
    function isA(item: Item): item is { type: 'a'; a: string } {
      return typeof item === 'object' && item !== null && (item as any).type === 'a';
    }
    
    const mixed: Item[] = [{ type: 'a', a: 'x' }, 'oops', { type: 'b', b: 2 }];
    for (const el of mixed) {
      if (isA(el)) {
        // el is narrowed to { type: 'a'; a: string }
        console.log(el.a.toUpperCase());
      }
    }

    For deeper patterns and how to make robust, reusable guards, read our guide on Using Type Predicates for Custom Type Guards. It shows strategies for composition, generics, and ensuring runtime checks align with type definitions.

    3. Discriminated Unions: Structured Mixed Arrays

    When array elements are objects with a tag field, discriminated unions are a clear fit. They provide simple, efficient narrowing without heavy runtime checks.

    ts
    type Shape =
      | { kind: 'circle'; radius: number }
      | { kind: 'rect'; width: number; height: number };
    
    const shapes: Shape[] = [
      { kind: 'circle', radius: 10 },
      { kind: 'rect', width: 5, height: 3 }
    ];
    
    for (const s of shapes) {
      if (s.kind === 'circle') {
        console.log(Math.PI * s.radius ** 2);
      } else {
        console.log(s.width * s.height);
      }
    }

    This pattern works extremely well in state reducers, event systems, and message handlers. If your mixed array stores domain events or heterogeneous payloads, discriminants simplify both types and runtime code. See related patterns when typing event emitters in our article on Typing Event Emitters in TypeScript: Node.js and Custom Implementations.

    4. Tuples vs Array of Unions: Choose by Intent

    Tuples capture fixed-length, positionally typed arrays. If you need position-specific semantics, prefer tuples.

    ts
    type Point = [number, number];
    const p: Point = [0, 10];
    // p[0] is number, p[1] is number
    
    // Alternating tuple example
    type Pair = [string, number];
    const list: Pair[] = [['a', 1], ['b', 2]];

    Contrast this with Value[] where positions are not meaningful. Tuples give precise types for map, destructuring, and function arguments. When you accept variable lengths with a known prefix you can use rest in tuples:

    ts
    type Log = [Date, ...string[]];

    This expresses a required Date followed by any number of strings. Use tuples when the contract depends on order or fixed slots.

    5. Safe Indexing and noUncheckedIndexedAccess

    Indexing into mixed arrays is a source of runtime errors. By default TypeScript assumes accessing an array by index yields the element type but not necessarily undefined. Enabling the compiler option noUncheckedIndexedAccess makes array indexing return possibly undefined, forcing you to handle missing elements.

    Example benefit:

    ts
    // with noUncheckedIndexedAccess
    const arr: (string | number)[] = [];
    const maybe = arr[0]; // type is string | number | undefined
    if (maybe !== undefined && typeof maybe === 'string') {
      console.log(maybe.toUpperCase());
    }

    This option tightens safety for arrays that may be empty or sparse. Learn more about safe indexing and migration strategies in our deep dive on Safer Indexing in TypeScript with noUncheckedIndexedAccess.

    6. Mapping and Transforming Heterogeneous Arrays with Conditional Types

    Often you need to transform mixed arrays while preserving type information. Conditional and mapped types let you compute result types based on input unions.

    Example: map an array of payloads to handlers using a mapped helper

    ts
    type Payload = { kind: 'a'; v: string } | { kind: 'b'; n: number };
    
    type Handler<T> = T extends { kind: infer K }
      ? K extends 'a'
        ? (p: Extract<Payload, { kind: 'a' }>) => void
        : (p: Extract<Payload, { kind: 'b' }>) => void
      : never;
    
    // A simple dispatch function preserving types
    function dispatch<P extends Payload>(p: P, h: Handler<P>) {
      h(p as any);
    }

    Use Extract and conditional checks to keep transformations type safe. This becomes very powerful in libraries that manipulate heterogeneous collections; see solving complex type challenges in our collection on Solving TypeScript Type Challenges and Puzzles.

    7. Readonly, Immutability and as const

    When arrays contain literals you may want to preserve exact literal types. Use as const or readonly tuples to lock types and values.

    ts
    const fixed = ['ok', 200] as const; // type readonly ['ok', 200]
    
    function acceptResponse(r: readonly [string, number]) {
      // r preserves literal types where useful
    }
    acceptResponse(fixed);

    Readonly arrays also guard against accidental mutation which can be especially important when different parts of a program rely on a stable heterogeneous structure. Use readonly and const assertions to encode invariants in the type system and avoid runtime surprises.

    8. Runtime Validation and Schemas for Mixed Arrays

    When data originates from untyped sources you need validation. Runtime schemas align runtime checks with TypeScript types. Libraries like zod, io-ts, and yup provide declarative schemas for mixed arrays.

    Example with a simple validation function:

    ts
    import { z } from 'zod';
    
    const Schema = z.array(z.union([
      z.object({ type: z.literal('a'), a: z.string() }),
      z.string()
    ]));
    
    const raw = fetchSomeData();
    const parsed = Schema.parse(raw); // throws if shape mismatches

    If your mixed arrays are part of configuration or environment data, combining schemas with TypeScript types reduces runtime errors. See related patterns in our article on Typing Environment Variables and Configuration in TypeScript.

    9. Performance Considerations for Large Heterogeneous Arrays

    When working with large mixed arrays keep performance in mind. Type checks at runtime can be expensive if done repeatedly. Batch validation, memoize parsing results, or use streaming parsers for very large data.

    Tip: Validate once at input boundary and then work with typed, validated data internally. This avoids repeated guards in hot loops. Also prefer simple discriminants for cheap narrowing instead of heavy structural checks inside tight loops.

    If you are building data fetching utilities that return mixed arrays, follow patterns in our case study about Typing a Data Fetching Hook which shows how to validate and cache results safely.

    Advanced Techniques

    Advanced patterns help when arrays have conditional structure or when you need to infer types from data. Variadic tuple types allow pattern matching on tuple heads and tails, while template literal types can help encode union member keys. Example: infer all string literal 'kind' values from a union and create a mapping type. Combined mapped and distributive conditional types let you transform unions elementwise. Use utility types like Extract, Exclude, and ReturnType to manipulate union-element types.

    Another advanced idea is to model a heterogeneous collection as a tuple of unions where each position represents a specific role. You can then write generic functions constrained by the tuple's shape. For runtime safety use inline discriminants and assert upfront via a lightweight schema.

    When debugging tricky narrowing bugs, log the runtime shapes and create minimal reproduction cases. Tools like type playgrounds and the compiler's type display help explore inferred types. For large projects, use focused unit tests that validate your type guard functions and schema interactions.

    If you enjoy solving type-level puzzles to model arrays precisely, explore further examples at Solving TypeScript Type Challenges and Puzzles for inspirational exercises.

    Best Practices & Common Pitfalls

    Do:

    • Prefer discriminated unions or tuples when structure is well defined.
    • Validate external data at boundaries and then rely on typed internal data.
    • Use type predicates for reusable narrowing logic.
    • Enable noUncheckedIndexedAccess to force handling of undefined when indexing.
    • Use readonly and as const to preserve literal information and prevent mutation.

    Don't:

    • Avoid overusing any or blind type assertions to silence the compiler. This can hide real runtime bugs. See security considerations in our guide on Security Implications of Using any and Type Assertions in TypeScript.
    • Don't do heavy per-element validation inside performance critical loops. Validate once or design cheap discriminants.
    • Avoid deeply nested unions when a discriminant field would make things simpler.

    Troubleshooting tips:

    • If narrowing doesn't work, check that your predicate uses the correct type predicate syntax: function isX(v: unknown): v is X.
    • If your editor shows a union type where you expected a tuple, inspect how values are constructed. const assertions often fix widening issues.
    • For arrays deserialized from JSON, ensure you run a schema parse early. Many bugs come from trusting unvalidated payloads.

    Real-World Applications

    Mixed arrays appear in many practical contexts: message buses that deliver different event types, logs that mix strings and structured entries, form inputs that accept strings and numeric values, and configuration files that allow mixed lists. When building state containers, heterogeneous arrays often represent different kinds of state items; see our case study on Typing a State Management Module for patterns on managing and typing mixed state safely.

    Form libraries frequently accept arrays of controls or mixed field values; our case study on Typing a Form Management Library (Simplified) highlights patterns for typing form-related heterogeneous collections. For event-driven systems, the emitter pattern benefits from discriminants and clear runtime guards; learn more at Typing Event Emitters in TypeScript: Node.js and Custom Implementations.

    Conclusion & Next Steps

    Typing mixed-type arrays is about matching intent with the right construct. Arrays of unions are simple, tuples encode position, and discriminated unions encode structured alternatives. Use type predicates and runtime schemas when the data boundary is untrusted, and enable stricter compiler flags to catch indexing mistakes early. Next, practice by converting a few real-world arrays in your codebase: start by adding discriminants, then add type guards, and finally extract schemas. For hands-on practice, try some exercises from Solving TypeScript Type Challenges and Puzzles.

    Enhanced FAQ

    Q1: When should I use an array of unions vs a union of tuples?

    A1: Use an array of unions (T[]) when each element is independently one of several alternatives and there is no positional significance. Use a union of tuples when the array's overall shape matters, for example when the array length or the type at a specific index is important. If you need sequences like [string, number, string] repeatedly, a tuple captures that contract precisely.

    Q2: Are discriminated unions always better for object variants?

    A2: Discriminated unions are typically better when each variant is an object with a small, consistent tag field. They allow cheap narrowing via tag checks and integrate nicely with switch statements. However, if variants are primitive values with no natural tag, you may need custom guards or wrapper objects.

    Q3: How do I avoid runtime errors when indexing mixed arrays?

    A3: Enable noUncheckedIndexedAccess to force the compiler to treat arr[i] as maybe undefined. Then guard against undefined before using the value. Also prefer safe APIs like Array.prototype.at with checks, and validate input arrays early. See Safer Indexing in TypeScript with noUncheckedIndexedAccess for more.

    Q4: What is the performance cost of runtime validation for mixed arrays?

    A4: Runtime validation costs depend on schema complexity and array size. For moderately sized arrays, validation is trivial. For very large arrays, validate in batches, validate only the necessary fields, or stream-validate as you process. Optimize by using simple discriminants and avoid deep structural checks in hot paths.

    Q5: When is it okay to use type assertions or any with mixed arrays?

    A5: Use assertions only when you have a guarantee outside the compiler's knowledge, and document or isolate that assertion. Avoid spreading any through the codebase. For untrusted external data, prefer explicit schema parsing over assertion. For migration, minimal local assertions can be acceptable but add tests and a remediation plan. See security guidance in Security Implications of Using any and Type Assertions in TypeScript.

    Q6: How do mapped and conditional types help with heterogeneous arrays?

    A6: Mapped and conditional types let you compute result types based on union members. For example you can map each union member to a handler type or transform payload shapes. They help you preserve precise types through transformations and create strongly typed dispatch or routing tables.

    Q7: What about JSON from external APIs that sometimes returns a single object or an array?

    A7: Normalize at the boundary. Write a small adapter that converts single objects into arrays or vice versa, then validate using a schema. This centralizes handling and prevents brittle checks scattered through your code. Schema libraries often support such normalization rules.

    Q8: Can I get good editor support for heterogeneous arrays in JavaScript projects?

    A8: Yes. Use JSDoc typedefs and type annotations to teach the editor, or gradually migrate files to TypeScript. Our guide on Using JSDoc for Type Checking JavaScript Files covers patterns to get type checking without a full TS migration.

    Q9: How do I model arrays where elements evolve over time, e.g., migrations of schema versions?

    A9: Include a version discriminant field and treat different versions as union members. Write migration functions that accept the older variant and return the newer shape. Use schema validation at the ingestion boundary to detect older versions and run migrations deterministically.

    Q10: Where can I learn more applied examples around typing arrays and related tooling?

    A10: Look at practical case studies in our library. For handling arrays in data fetching workflows see Practical Case Study: Typing a Data Fetching Hook. For stateful collections check Practical Case Study: Typing a State Management Module. Also consider reading the article about configuration typing Typing Configuration Objects in TypeScript: Interfaces vs Type Aliases — An Intermediate Guide when mixed arrays live in config.

    Additional resources

    If you want, I can convert specific examples from your codebase into typed patterns, or generate a set of type guards and runtime schemas tailored to your API responses. Just paste a sample JSON payload or a TypeScript file and I will produce a migration plan.

    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:02 PM
    Next sync: 60s
    Loading CodeFixesHub...