Search code examples
typescriptfrontendrecord

Can typescript check every Record value of Enum?


Typescript is beautiful, and I love to write on typescript cause I can just write

enum Test {
    some = 'some',
    another = 'another',
}

const test: Record<Test, string> = {
    some: 'string'
}

then typescript asks me to type every key value from Test as the key of my test object

Property 'another' is missing in type '{ some: string; }' but required in type 'Record<Test, string>'

But if I want to write an opposite operation (like parse/stringify), I want to be sure that I don't miss some Test as values of Record

enum Test {
    some = 'some',
    another = 'another',
}
const test: Record<string, Test> = {
    some: Test.some
}

But typescript assume that it is correct.

I would like to throw some error by typescript.

Is there any way in typescript to check every Record value of Enum?


Solution

  • TypeScript doesn't really have the idea of "exhaustive property types", but you could write a generic type along with a helper function to validate that a given value behaves as you like. For example:

    const exhaustive = <V,>() => <T extends Record<keyof T, V>>(
        t: { [K in keyof T]: [V] extends [T[keyof T]] ? T[K] : Exclude<V, T[keyof T]> }
    ) => t;
    

    This is a curried function where you manually specify the value type V you'd like to "exhaust", and it outputs another generic function that does the check:

    enum Test {
        some = 'some',
        another = 'another',
    }   
    const exhaustiveTest = exhaustive<Test>()
    

    (The reason you need to use two functions like that is because TypeScript doesn't let you manually specify a generic type argument while inferring another one; there is an open feature request for this at microsoft/TypeScript#26242 and the various workarounds including currying are discussed in Typescript: infer type of generic after optional first generic)


    When you call exhaustiveTest() it will return its input, but a compiler error will occur if you are missing any of the values in Test:

    const test = exhaustiveTest({
        abc: Test.some,
        def: Test.another
    }); // okay
    
    const badTest = exhaustiveTest({
        abc: Test.some // error! Type 'Test.some' is not assignable to type 'Test.another'
    })
    

    Hooray, that's what you wanted!


    The way it works is that the call to exhaustiveTest() infers the type argument T to be the type passed in as the function argument t, but it checks it against the mapped type { [K in keyof T]: [Test] extends [T[keyof T]] ? T[K] : Exclude<Test, T[keyof T]> }. Each property is the conditional type [Test] extends [T[keyof T]] ? T[K] : Exclude<Test, T[keyof T]>. That means: if every value of type Test can be assigned to one of the property types of T, then each property of T is just fine as itself (the indexed access type T[K]); otherwise, it should be Exclude<Test, T[keyof T]> using the Exclude<T, U> utility type to filter out all the properties of T from Test to get just the missed values, so that the error message will mention these missing values.

    As a concrete case, if T is {abc: Test.some; def: Test.another}, then T[keyof T] is Test.some | Test.another, which is the same as Test, and so the mapped type is just {abc: Test.some; def: Test.another}, and it type checks. But if T is just {abc: Test.some}, then T[keyof T] is just Test.some, which is missing thing from Test. And so the mapped type is {abc: Test.another}, and it fails to type check, complaining about the missing thing.

    Playground link to code