Search code examples
typescripttypestuples

Explicitly infer tuple type


Typescript version: 5.1.6

Here is a minimal reconstruction of my problem:

I have a function f which is gonna take an array of C where

declare class C<a = unknown, b = unknown> {
  getA(): a;
  getB(): b;
};

The first generic of C, which is A is going to be an object.

I want to explicitly get the array of C as a tuple in the f

declare function f<T extends any[]>(arr: [...T]): any;

declare const a: C<{ name: string }, []>;
declare const b: C<{ name: string }, []>;

f([a, b])

Hovering on the f we can see exactly what we want.

function f<[C<{
    name: string;
}, []>, C<{
    name: string;
}, []>]>(arr: [C<{
    name: string;
}, []>, C<{
    name: string;
}, []>]): any

The problem comes when I want to wrap the array type with another type, which is going to check if there are duplicate keys on the T.

When I do something like this:

type ExtractObject<I> = I extends C<infer P> ? P : never;
type ExtractObjectFromList<I> = {
  [P in keyof I]: ExtractObject<I[P]>;
};


declare function f<T extends any[]>(arr: HasDuplicateKey<ExtractObjectFromList<[...T]>> extends true ? "Err" : T): any;

declare const a: C<{ name: string }, []>;
declare const b: C<{ name: string }, []>;

f([a, b])

or

declare function f<T extends any[]>(arr: [...T] extends infer P ? HasDuplicateKey<ExtractObjectFromList<P>> extends true ? "err" : P: never): any;

declare const a: C<{ name: string }, []>;
declare const b: C<{ name: string }, []>;

f([a, b])

it is not working correctly since it is losing the tuple type and it is converted to (C<{ name: string }, []>[])[]

I wonder if there is a solution to this without using as const when passing the array, this is part of a library and I want to be backwards compactable.

So the idea is to highlight to display an error message when the keys of the first generic in C has a collision and also ability to do some more checks on top of that

Also this is the HasDuplicateKey type

export type HasDuplicateKey<T> = T extends readonly [infer First, ...infer Rest]
  ? Rest extends readonly [infer Next, ...infer Others]
    ? keyof First extends keyof Next
      ? true
      : HasDuplicateKey<readonly [First, ...Others]>
    : false
  : false;

Solution

  • You're looking to emulate a circular generic constraint function f<T extends F<T>>(arg: T) {} where F<T> is a "validation" type function, where T extends F<T> essentially evaluates to some supertype of T if and only if T is "valid". Sometimes you can actually write such a constraint directly, but often the compiler will complain that it is circular.

    There are various approaches to emulating such constraints, but it can be hard to find one that doesn't mess up the compiler's ability to infer T from the argument passed in for arg. It's fragile.

    Sometimes you can write function f<T>(arg: F<T>) {} and it works. Sometimes that doesn't work but function f<T>(arg: T & F<T>) {} works. Sometimes that doesn't work but function f<T>(arg: T extends F<T> ? T : F<T>) {} works. And sometimes none of those work; it looks like when T is supposed to be a tuple type and you use a variadic tuple type hint function f<T extends any[]>(arg: [...F<T>]) {} then it often just... breaks.

    In such cases you can try rewriting the validation type function F<T> in terms of a a validation type function G<T> that acts on each tuple element individually, so F<T> would be a homomorphic mapped type (see What does "homomorphic mapped type" mean?) of the form {[I in keyof T]: G<T[I]>}. This seems to preserve the inference behavior more or less, but requires that you refactor the check.


    For the example as shown here, I'd probably write it like this:

    declare function f<T extends C<any, any>[]>(
      arr: [...DeDuplicateKeys<T>]
    ): T;
    
    type DeDuplicateKeys<T extends C<any, any>[]> =
      { [I in keyof T]: T[I] &
        C<Record<RepeatsAtIndex<Keys<T>, I>, never>, any>
      }
    
    type RepeatsAtIndex<T, I> =
      T[Extract<keyof T, I>] & T[Exclude<keyof T, I>];
    
    type Keys<T extends C<any, any>[]> =
      { [I in `${number}` & keyof T]:
        T[I] extends C<infer A, any> ? keyof A : never
      };
    

    So DeDuplicateKeys<T> is our homomorphic mapped type which applies the check T[I] & C<Record<RepeatsAtIndex<Keys<T>, I>, never>, any> to each tuple element of T at index I.

    The way that works is to take T and map it to a new tuple-like type containing just the keys of the A type argument of C<A, B>; that's what Keys<T> does (it's not a real tuple, but has the numeric like indices "0", "1", etc). And then RepeatsAtIndex<Keys<T>, I> will give you just those keys which occur in the Ith element of the tuple which also appear in some other element. The hope is that this will be never. And then Record<RepeatsAtIndex<Keys<T>, I>, never> is an object type with all those repeated keys and a value type of never. If there are any repeated keys at all, this will make an impossible type like {name: never}. Otherwise it will be the permissive empty object type {}. By intersecting with T[I], we will either get just T[I] if it's good, or something impossible if it's bad.

    Frankly it's complicated, but your constraint is complicated, because it acts on one type argument of a generic type inside the tuple elements.


    Anyway, let's test it out:

    declare const a: C<{ name: string }, []>;
    declare const b: C<{ name: string }, []>;
    declare const c: C<{ age: number }>;
    f([a, c]); // okay
    // function f<[C<{ name: string; }, []>, C<{ age: number; }, unknown>]>(⋯): ⋯;
    f([b, c]); // okay
    // function f<[C<{ name: string; }, []>, C<{ age: number; }, unknown>]>(⋯): ⋯;
    f([a, b, c]); // error
    // ~  ~ <-- types of property "name" are incompatible
    // function f<[
    //   C<{ name: string }, []>, C<{ name: string }, []>, C<{ age: number }, unknown>
    // ]>(⋯): ⋯;
    

    Looks like what you wanted. The T type argument is inferred in each case to be the correct tuple type, and valid combinations are accepted, while invalid combinations are rejected.


    Note that there may well be edge cases not accounted for here, but the general approach I'd take here would still be to try to write a validation type function out of a homomorphic mapped type.

    Playground link to code