Search code examples
typescripttypescript-generics

Construct TypeScript union conditionally based on whether some values are present in array


I'm trying to create a generic function in TypeScript that accepts an argument and based on that argument, makes a parameter required and sets its type.

Consider the following:

type definingArg = {val: Array<null | "VAL1" | "VAL2">};
const func = <T extends definingArg>(dArg: T, g: help): void => { ... };

What I'm trying to get is the following:

Depending on the type of dArg, g is either optional or required (I can do that using conditional spread arguments) AND dArg controls the acceptable values of g. That effectively means I need to somehow create a union based on values present in an array.

This can be untangled like so:

  • dArg extends [null] -> g becomes undefined AND optional
  • dArg[number] extends null -> g becomes optional, undefined acceptable (= in union)
  • dArg[number] extends "VAL1" -> in union: {simpleVal: string}
  • dArg[number] extends "VAL2" -> in union: {complex: string, val: number}

Examples:

func( [null],         x?: undefined )
func( [null, "VAL1"], x?: undefined | {simpleVal: string} )
func( ["VAL2"],       x: {complex: string, val: number} )

Because of other things the function will need to do, I'd very much like to avoid overloading.


Solution

  • TypeScript doesn't have a built-in operator to mark a function parameter as conditionally optional. It's either optional or it's not, and it's a separate syntax. There is a longstanding open feature request at microsoft/TypeScript#12400 to treat all parameters that accept undefined as optional, but it's not part of the language. So we'll need to work around it.

    What we can do is give a function a rest parameter of a a tuple type. There is an equivalence between regular function parameters and rest parameters of tuple types. A call signature like (a: string, b: number) => void is equivalent to (...args: [string, number]) => void. And tuples can have optional elements, so (a: string, b?: number) => void is equivalent to (...args: [string, number?]) => void. So instead of trying to conditionally make a regular function parameter optional, we can conditionally produce a tuple with an optional element.

    So your function might look like

    const func = <T extends ⋯>(dArg: ⋯T⋯, ...[x]: ⋯T⋯): void => { };
    

    where the rest parameter is destructured into x (so inside func you can still refer to it as x, if it matters), and the type of the [x] array depends on the [generic](So if https://www.typescriptlang.org/docs/handbook/2/generics.html) type argument T in some way we need to figure out.


    There are multiple approaches that you can take starting from this basic plan. All of them are at least somewhat clunky. A natural approach is to use conditional types to check T and do different things depending on it. But it might be a little "neater" to use T as a key to look up in a data structure:

    type ArgMap = {
      VAL1: [{ simpleVal: string }],
      VAL2: [{ complex: string, val: number }],
      null: [undefined?]
    }
    
    const func = <T extends null | "VAL1" | "VAL2">(
      dArg: Array<T>, ...[x]: ArgMap[`${T}`]
    ): void => { };
    

    Here T is the element type of the dArg parameter, so it's either null, "VAL1", "VAL2", or some union of those. Note that "VAL1" and "VAL2" are already string literal types and therefore we can use them as keys directly. But null is less conducive to that. So I've used a template literal type as a trick. If you serialize null as a template literal type you get the string literal type "null". So while T directly can't be looked up in ArgMap, the type `${T}` can.

    So we look up the serialized T in the ArgMap data structure, and indexed accesses distribute across unions. So if T is just "VAL1" then ArgMap[`${T}`] is [{simpleVal: string}]. But if it's a union like "VAL1" | "VAL2" then ArgMap[`${T}`] is [{simpleVal: string}] | [{ complex: string, val: number }]. And "null" will map to [undefined?], meaning a tuple with an optional element which could be undefined if you want.

    That has the behavior you desire:

    // const func: <null>(dArg: null[], x?: undefined) => void
    func([null]); // okay
    func([null], undefined);
    
    // const func: <"VAL1" | null>(dArg: ("VAL1" | null)[], x?: { simpleVal: string; }) => void
    func([null, "VAL1"], { simpleVal: "abc" }); // okay
    func([null, "VAL1"]); // okay
    
    // const func: <"VAL2">(dArg: "VAL2"[], x: { complex: string; val: number; }) => void
    func(["VAL2"], { complex: "abc", val: 123 }); // okay
    func(["VAL2"]); // error
    

    The presence of null in the input array makes the second argument optional, and indeed IntelliSense even shows it as a parameter named x with a ? after it. The absence of null in the input array makes the second argument required. And the type of the second argument depends on T also.


    If T were not sufficiently keylike then a series of conditional types would be your only recourse, something like:

    const func = <T extends null | "VAL1" | "VAL2">(
      dArg: Array<T>, ...[x]:
        T extends null ? [undefined?] :
        T extends "VAL1" ? [{ simpleVal: string }] :
        T extends "VAL2" ? [{ complex: string, val: number }] :
        never
    ): void => { };
    

    which is wordier but maybe more straightforward for those readers with a similar but slightly different use case. (Note that these are distributive conditional types so each union member of T will be processed independently of the others.)

    Playground link to code