Search code examples
typescript

Create mapping based on enums including enforcing compiler errors


I need to enforce a mapping on an object. The aim is to create a type or interface which maps from ANIMAL_PLACE to ANIMAL_TYPE. This type I then want to use to create an object of the needed key:value pairs. But, I need the compiler to throw an error when I forget to add either ANIMAL_PLACE or ANIMAL_TYPE to object. I am only able to get it halfway done.

export enum ANIMAL_TYPE {
    dog = "dog",
    cat = "cat",
    fish = "fish",
    foo = "foo"
}

export enum ANIMAL_PLACE {
        europe = 'europe',
        africa = 'africa',
        asia = "asia"
}

// This does not throw a compiler error when I forget to add ANIMAL_PLACE nor ANIMAL_TYPE to type
type AnimalPlaceToAnimalMapType = {
    [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish]
    [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog]
}



// this only throws a compiler error when I forget to add ANIMAL_PLACE to record
const AnimalPlaceToAnimalMapRecord: Record<ANIMAL_PLACE, ANIMAL_TYPE[]> = {
    [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish],
    [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog],
};

Here is a Playground

If it is not possible to throw a compiler error when either of the two changes, is there a way get a compiler error, when I forget to include one of ANIMAL_TYPE instead of ANIMAL_PLACE...?


Solution

  • You can write a utility type to require that two types are "the same" (although the test I write actually just make it require that the two types are mutually assignable, which is good enough for your purposes):

    type Same<T extends U, U extends V, V = T> = void;
    

    This doesn't evaluate to anything important (Same<T, U> always evaluates to void), but the constraints on T and U make it so that if you write Same<T, U> with different types, either T or U will cause a compiler error:

    type T1 = Same<string, string>; // okay
    type T2 = Same<string, number>; // error!
    //             ~~~~~~
    // Type 'string' does not satisfy the constraint 'number'.
    type T3 = Same<"abc", string>; // error!
    //                    ~~~~~~
    // Type 'string' does not satisfy the constraint '"abc"'.
    

    (Note that conceptually it would be type Same<T extends U, U extends T> = void, but TypeScript rejects that as an illegally circular constraint. The workaround is to have U extends V, where V is a third type parameter which defaults to T. So when you use Same<T, U>, it's like writing Same<T, U, T>, and then TS will complain if U extends T is false.)

    So then we can make sure that the union of AnimalPlaceToAnimalMapType's keys is identical to ANIMAL_PLACE type, and that the union of AnimalPlaceToAnimalMapType's array element types are identical to ANIMAL_TYPE:

    type VerifyMap =
        Same<keyof AnimalPlaceToAnimalMapType, ANIMAL_PLACE> &
        Same<AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType][number], ANIMAL_TYPE>
    

    Note that AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType] is the union of values of AnimalPlaceToAnimalMapType, since it's an indexed access with a union of keys. We expect that to be a union of array/tuple types, so indexing into that with number gives us the union of their element types.


    So, if we have defined AnimalPlaceToAnimalMapType correctly, then you won't get any errors:

    type AnimalPlaceToAnimalMapType = {
        [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish]
        [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog]
        [ANIMAL_PLACE.asia]: [ANIMAL_TYPE.foo]
    }
    
    type VerifyMap =
        Same<keyof AnimalPlaceToAnimalMapType, ANIMAL_PLACE> &
        Same<AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType][number], ANIMAL_TYPE> // okay
    

    If you are missing an element from ANIMAL_PLACE then you get an error on the first check:

    type AnimalPlaceToAnimalMapType = {
        [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish]
        [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog, ANIMAL_TYPE.foo]
    }
    
    type VerifyMap =
        Same<keyof AnimalPlaceToAnimalMapType, ANIMAL_PLACE> & // error!
        Same<AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType][number], ANIMAL_TYPE>
    

    And if you're missing an element from ANIMAL_TYPE then you get an error on the second check:

    type AnimalPlaceToAnimalMapType = {
        [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish]
        [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog]
        [ANIMAL_PLACE.asia]: []
    }
    
    type VerifyMap =
        Same<keyof AnimalPlaceToAnimalMapType, ANIMAL_PLACE> &
        Same<AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType][number], ANIMAL_TYPE> // error!
    

    So that's the basic approach I would take to enforce out-of-band constraints on your types. The VerifyMap above might not be sufficient for every possible check of AnimalPlaceToAnimalMapType; it checks for missing an extra things, but it doesn't force there not to be repeats. You could write a check for that too, such as by requiring the intersection of all the array elements of the property values have no overlap:

    type AnimalPlaceToAnimalMapType = {
        [ANIMAL_PLACE.africa]: [ANIMAL_TYPE.cat, ANIMAL_TYPE.fish]
        [ANIMAL_PLACE.europe]: [ANIMAL_TYPE.dog]
        [ANIMAL_PLACE.asia]: [ANIMAL_TYPE.foo, ANIMAL_TYPE.cat], // repeat
    }
    
    type APAMT = AnimalPlaceToAnimalMapType;
    type ISect = { [K in keyof APAMT]: { [P in keyof APAMT]:
        P extends K ? never : APAMT[P][number] & APAMT[K][never]
    }[keyof APAMT] }[keyof APAMT]
    
    type VerifyMap =
        Same<keyof AnimalPlaceToAnimalMapType, ANIMAL_PLACE> &
        Same<AnimalPlaceToAnimalMapType[keyof AnimalPlaceToAnimalMapType][number], ANIMAL_TYPE> &
        Same<ISect, never>; // error!
    

    I won't go into detail for how that works as it's outside the scope of the question as asked. The point is that you can usually write some kind of utility type to check the invariants you want to impose.

    Playground link to code