Typescript type guard for requiring all elements

前端 未结 3 1947
旧时难觅i
旧时难觅i 2021-01-21 17:54

Is there a way to require array elements with typescript so that I can do have

type E = keyof T; // Number of properties in T is unknown

let T

相关标签:
3条回答
  • 2021-01-21 18:44

    You can create a tuple:

    type E = "el1"|"el2"|"el3";
    
    type ITestElement<T extends E> = {
        arg: T
    };
    
    type ITestElements = [ITestElement<"el1">, ITestElement<"el2">, ITestElement<"el3">];
    
    
    0 讨论(0)
  • 2021-01-21 18:49

    I'm going to define this:

    type Arg<T> = T extends any ? { arg: T } : never;
    

    so that we can use Arg<E> (equivalent to {arg:"el1"}|{arg:"el2"}|{arg:"el3"}) in what follows.


    The best you can hope for here would be some generic helper function verifyArray() which would enforce the restrictions that its argument is:

    • an array of elements from a union
    • missing no elements from the union
    • and containing no duplicates

    And it's going to be ugly.


    There's no usable concrete type that will enforce this for unions containing more than about six elements. It is possible to use some illegally-recursive or legally-nonrecursive-but-tedious type definitions to take a union type like 0 | 1 | 2 | 3 and turn it into a union of all possible tuples that meet your criteria. That would produces something like

    type AllTuples0123 = UnionToAllPossibleTuples<0 | 1 | 2 | 3>
    

    which would be equivalent to

    type AllTuples0123 =
        | [0, 1, 2, 3] | [0, 1, 3, 2] | [0, 2, 1, 3] | [0, 2, 3, 1] | [0, 3, 1, 2] | [0, 3, 2, 1]
        | [1, 0, 2, 3] | [1, 0, 3, 2] | [1, 2, 0, 3] | [1, 2, 3, 0] | [1, 3, 0, 2] | [1, 3, 2, 0]
        | [2, 0, 1, 3] | [2, 0, 3, 1] | [2, 1, 0, 3] | [2, 1, 3, 0] | [2, 3, 0, 1] | [2, 3, 1, 0]
        | [3, 0, 1, 2] | [3, 0, 2, 1] | [3, 1, 0, 2] | [3, 1, 2, 0] | [3, 2, 0, 1] | [3, 2, 1, 0]
    

    But for an input union of n elements that would produce an output union of n! (that's n factorial) outputs, which grows very quickly in n. For your example "el1"|"el2"|"el3" it would be fine:

    type AllPossibleTuplesOfArgs = UnionToAllPossibleTuples<Arg<E>>;
    const okay: AllPossibleTuplesOfArgs = 
      [{ "arg": "el1" }, { "arg": "el2" }, { "arg": "el3" }]; // okay
    const bad1: AllPossibleTuplesOfArgs = [{ "arg": "el1" }, { "arg": "el2" }]; // error!
    const bad2: AllPossibleTuplesOfArgs = // error!
      [{ "arg": "el1" }, { "arg": "el2" }, { "arg": "el2" }, { "arg": "el3" }];
    const bad3: AllPossibleTuplesOfArgs = 
      [{ "arg": "el1" }, { "arg": "el2" }, { "arg": "el8" }]  // error!
    

    but I assume you want something that doesn't crash your compiler when your object has seven or more properties in it. So let's give up on UnionToAllPossibleTuples and any concrete type.


    So what would verifyArray() look like?

    First let's make a type function called NoRepeats<T> which takes a tuple type T and returns the same thing as T if and only if T has no repeated elements... otherwise it returns a modified tuple to which T is not assignable. This will allow us to make the constraint T extends NoRepeats<T> to say "the tuple type T has no repeated elements". Here's a way to do it:

    type NoRepeats<T extends readonly any[]> = { [M in keyof T]: { [N in keyof T]:
        N extends M ? never : T[M] extends T[N] ? unknown : never
    }[number] extends never ? T[M] : never }
    

    So NoRepeats<[0,1,2]> is [0,1,2], but NoRepeats<[0,1,1]> is [0,never,never]. Then verifyArray() might be written as this:

    const verifyArray = <T>() => <U extends NoRepeats<U> & readonly T[]>(
        u: (U | [never]) & ([T] extends [U[number]] ? unknown : never)
    ) => u;
    

    It takes a type T to check against, and returns a new function which makes sure its argument has no repeats (from U extends NoRepeats<U>), is assignable to T[] (from & readonly T), and not missing any elements of T (from & ([T] extends [U[number]] ? unknown : never)). Yes it's ugly. Let's see if it works:

    const verifyArgEArray = verifyArray<Arg<E>>()
    const okayGeneric = verifyArgEArray([{ "arg": "el3" }, { "arg": "el1" }, { "arg": "el2" }]); // okay
    const bad1Generic = verifyArgEArray([{ "arg": "el1" }, { "arg": "el2" }]); // error
    const bad2Generic = // error
        verifyArgEArray([{ "arg": "el1" }, { "arg": "el2" }, { "arg": "el2" }, { "arg": "el3" }]);
    const bad3Generic = // error
        verifyArgEArray([{ "arg": "el1" }, { "arg": "el2" }, { "arg": "el8" }]);
    

    So that works.


    Both of these force you to fight with the type system. You could possibly make a builder class as in this answer which plays more nicely with the type system but involves even more runtime overhead, and is arguably only slightly less ugly.


    Honestly I'd suggest trying to refactor your code not to require TypeScript to enforce this. The easiest thing is to require an object have these values as keys (e.g., just make a value of type T or possibly Record<keyof T, any>) and use that instead of (or before producing) an array. Oh well, hope this helps. Good luck!

    Playground link to code

    0 讨论(0)
  • 2021-01-21 18:50

    I have a solution for enforcing at compile-time that an array contains all values of some arbitrary type T. That's quite close to what you want - it's missing the check to prevent duplicates - but some people may not need to prevent duplicates (e.g. another question marked as a duplicate of this one doesn't have that requirement).

    The NoRepeats type from @jcalz's answer can probably be combined with this one if you want to enforce that additional requirement.


    We can enforce that an array contains every value of type T by writing a generic function with two type parameters. T is an arbitrary type, and A is the type of the array. We want to check that T[] and A are identical. A nice way to do this is to write a conditional type which resolves to never if these two types are not subtypes of each other:

    type Equal<S, T> = [S, T] extends [T, S] ? S : never;
    

    We want to specify the type parameter T explicitly, to tell the compiler what to check for, but we want A to be inferred from the actual value, so that the check is meaningful. Typescript doesn't allow specifying some but not all type parameters of a function, but we can get around this with currying:

    function allValuesCheck<T>(): <A extends T[]>(arr: Equal<A, T[]>) => T[] {
        return arr => arr;
    }
    

    Then, this function can be used to ask the compiler to check that an array literal contains all possible values of an arbitrary type:

    type Foo = 'a' | 'b';
    
    // ok
    const allFoo = allValuesCheck<Foo>()(['a', 'b']);
    // type error
    const notAllFoo = allValuesCheck<Foo>()(['a']);
    // type error
    const someNotFoo = allValuesCheck<Foo>()(['a', 'b', 'c']);
    

    The downside is that the type errors are not informative; the first error message just says Type 'string' is not assignable to type 'never', and the second error message just says Type 'string' is not assignable to type 'Foo'. So although the compiler informs you of the error, it doesn't tell you which one is missing or erroneous.

    Playground Link

    0 讨论(0)
提交回复
热议问题