typescripttuplesconditional-typesmapped-types

TypeScript conditionally map tuple optional elements


Is it possible in TypeScript to create a mapped type to add an optional modifier to a tuple type element conditionally? Specifically, I would like to map over a tuple type where elements could be undefined, and if so, set that element as optional. For instance:

type Input = [string, number | undefined]

type UndefinedToOptional<T> = { [K in keyof T}: T[K] extends undefined ? ... : ... } // ???

type Output = UndefinedToOptional<Input> // should be [string, (number | undefined)?]

I can create a mapped type that always adds the optional modifier:

type ToOptional<T> = { [K in keyof T]+?: T[K] }

type AllOptionalOutput = ToOptional<Input> // this is now [string?, (number | undefined)?]

But I'm unsure how to make the optional modifier conditional. For mapped types operating on objects, I would accomplish this by creating two object types and intersecting them, where all properties are set as optional then intersected with an object that picks out the required props, but I'm unsure how to accomplish something similar with tuples.


Solution

  • Note that there's a possible snag in the definition of what you want. Optional elements in tuple types are only allowed on elements where every element following is also optional. So you can write [1, 2?, 3?] but not [1?, 2?, 3]. That means if you have a tuple like [1, 2|undefined, 3, 4|undefined, 5|undefined], you can turn it into [1, 2|undefined, 3, 4?, 5?] or [1, 2?, 3?, 4?, 5?] but not [1, 2?, 3, 4?, 5?]. I assume the former (where 3 is required) is what you want, in what follows.


    There's nothing easy that does this, unfortunately. Tuple type manipulation in TypeScript is somewhat rudimentary. There's an open issue, microsoft/TypeScript#26223 that asks for part of this, with my comment here generalizing to the sort of arbitrary tuple manipulation needed to answer this question. Specifically from that comment, something like TupleLenOptional would be needed.

    It's possible to build an implementation of this out of the pieces TypeScript gives us, but there are drawbacks. The obvious drawback is that it's ugly and complicated; we have to use prepend-to-tuple and split-tuple-into-first-and-rest operations. Such an implementation is more taxing on the compiler than the ideal version where you'd presumably just use a mapped tuple.

    If TypeScript supported circular conditional types (see microsoft/TypeScript#26980 for the feature request) you'd want to use them. Since it doesn't, you either have to trick the compiler into allowing them, which is very much not supported... or you have to unroll the circular type into a redundant list of similar-looking types that only works to some fixed tuple length.

    Here's my implementation of the latter which should work on tuples up to length 10 or so:

    type Cons<H, T extends any[]> = ((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never;
    type Tail<T extends any[]> = ((...t: T) => void) extends ((h: any, ...r: infer R) => void) ? R : never;
    type CondPartTuple<T extends any[]> = Extract<unknown extends { [K in keyof T]: undefined extends T[K] ? never : unknown }[number] ? T : Partial<T>, any[]>
    type UndefinedToOptionalTuple<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT0<Tail<T>>>>
    type PT0<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT1<Tail<T>>>>
    type PT1<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT2<Tail<T>>>>
    type PT2<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT3<Tail<T>>>>
    type PT3<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT4<Tail<T>>>>
    type PT4<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT5<Tail<T>>>>
    type PT5<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT6<Tail<T>>>>
    type PT6<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT7<Tail<T>>>>
    type PT7<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT8<Tail<T>>>>
    type PT8<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PT9<Tail<T>>>>
    type PT9<T extends any[]> = CondPartTuple<T['length'] extends 0 ? [] : Cons<T[0], PTX<Tail<T>>>>
    type PTX<T extends any[]> = CondPartTuple<T>; // bail out
    

    The basic idea there: do a right fold (like reduceRight()) of the tuple type. At each step, you have the head of the tuple (first element) and the tail (the rest). Prepend the head to the tail, and then check to see ifundefined is assignable to every element of the result. If so, then change it to Partial. Otherwise leave it alone.

    This has the desired effect:

    type Result = UndefinedToOptionalTuple<[1, 2 | undefined, 3, 4 | undefined, 5 | undefined]>
    // type Result = [1, 2 | undefined, 3, (4 | undefined)?, (5 | undefined)?]
    

    But... yuck. I certainly wouldn't try to use the above in any production code base, since it bogs down the compiler and looks like a mess. So, make of it what you will.


    Okay, hope that helps; good luck!

    Playground link to code