Search code examples
typescripttuples

Tuple that accepts only one occurrence of specific type


I'm trying to write a function that accepts a tuple of a union type but a specific member of the union should only appear once (or not at all), e.g. [A, B, A] is valid but [A, B, A, B] is not. Ideally I could just write something like [...A[], B, ...A[]] | A[] but TypeScript doesn't allow it.

While looking for possible solutions I found this answer to a slightly different but related problem: applying a complex constraint to a tuple type. The solution basically intersects the tuple with a generic type that either resolves to unknown or never. Through the use of recursion each member of the tuple can be validated.

Unfortunately my attempt doesn't seem to work and I'm not sure why. Apparently I don't understand the solution for the linked question too well. Example code below:

type A = number;
type B = string;

type EnsureOne<Tuple, T, Seen = false> =
  Tuple extends [infer Head, ...infer Tail] ?
    Head extends T ?
      Seen extends true ?
        { ERROR: [`Only one value of type`, T, `is allowed`] } :
        EnsureOne<Tail, T, true> :
      EnsureOne<Tail, T, Seen> :
    unknown;

type ExpectUnknown = EnsureOne<[A, B, A], B>;
//   ^? unknown
type ExpectError = EnsureOne<[A, B, A, B], B>;
//   ^? { ERROR: ["Only one value of type", string, "is allowed"] }

type Union = A | B;

declare function f<const T extends readonly Union[]>(t: T & EnsureOne<T, B>): void;

const expectError = f([0, '', '']); // no error :(

Playground

When I use EnsureOne on its own it works as expected but in the intersection the constraint has no effect.


Solution

  • Your code should either use readonly or not, but be consistent

    Your f() function is generic in a const type parameter T which is constrained to the readonly array type readonly Union[]. That means when you call it like f([0, "a", "b"]) the compiler will infer the type argument for T as the readonly tuple type readonly [0, "a", "b"].

    But your EnsureOne type does not expect its first type argument to be a readonly tuple. You are comparing it to the generic variadic tuple type [infer Head, ...infer Tail]. Those don't match. So it becomes unknown and nothing gets rejected.

    You could either fix EnsureOne by allowing readonly:

    type EnsureOne<Tuple, T, Seen = false> =
        Tuple extends readonly [infer Head, ...infer Tail] ?
        //            ^^^^^^^^ 
        Head extends B ?
        Seen extends true ?
        { ERROR: [`Only one value of type`, T, `is allowed`] } :
        EnsureOne<Tail, T, true> :
        EnsureOne<Tail, T, Seen> :
        unknown;
    

    or you could fix f() and make the constraint not allow readonly:

    declare function f<const T extends Union[]>(t: T & EnsureOne<T, B>): void;
    

    or you could do both. Either way you'd get the behavior you want:

    declare function f<const T extends readonly Union[]>(t: T & EnsureOne<T, B>): void;
    
    f([0, "a", "b"]) // error!
    // Property 'ERROR' is missing in type '[0, "a", "b"]' but required 
    // in type '{ ERROR: ["Only one value of type", string, "is allowed"]; }'.
    f([0, 1, 2]); // okay
    f([0, 1, 2, "a"]); // okay
    f(["a", "b"]); // error!
    

    Another approach

    Or, you could rewrite your code to avoid recursive conditional types entirely. One way to do that is to use a mapped array type that compares the Ith element of T to the all the other elements, and if it ever finds that, for example, both the Ith and Jth element are assignable to B (or whatever type you want to check for) when J and I are different, then that element is in error. Error elements can be mapped to the never type or something wacky like { ERROR: ⋯ } (presumably to behave as an "invalid" type as described in microsoft/TypeScript#23689). Otherwise it's left alone.

    Perhaps like this:

    declare function g<T extends Union[]>(t: [...{ [I in keyof T]:
        T[I] extends B ? (
            unknown extends {
                [J in keyof T]: I extends J ? never : T[J] extends B ? unknown : never
            }[number] ? never : T[I]
        ) : T[I] }]): void;
    
    g([0, "a", "b"]); // error!
    g([0, 1, 2]); // okay
    g([0, 1, 2, "a"]); // okay
    g(["a", "b"]); // error!
    

    This also behaves as desired. I won't go into further detail about how this works, mostly because it seems the question really was about fixing the original approach, and not about implementing a different one.

    Playground link to code