Search code examples
arraystypescripttypesconstantsimmutability

How to typesafely filter an imutable array of arrays with unique values and keys?


    class Constants {
        static readonly STATES_AND_ACRONYMS = [
            ["AA", "NAME_A"],
            ["BB", "NAME_B"],
            ["CC", "NAME_C"],
            ["DD", "NAME_D"],
            ["EE", "NAME_E"],
            ["FF", "NAME_F"],
        ] as const;
    }
    
    type Acronym = typeof Constants.STATES_AND_ACRONYMS[number][0];
    type State = typeof Constants.STATES_AND_ACRONYMS[number][1];
    
    function getStateByAcronym(acronym : Acronym) : State{
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[0]==acronym)
        if(entry == undefined){
            //impossible
            return Constants.STATES_AND_ACRONYMS[0][1];
        }
    
        return entry[1];
    }
    
    function getAcronymByState(state : State) : Acronym{
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[1]==state)
        if(entry == undefined){
            //impossible
            return Constants.STATES_AND_ACRONYMS[0][0];
        }
        return entry[0];
    }

So, there is an array with states and their acronyms, each acronym as well the state name is unique and imuttable. Two types are created based on said array, Acronym and State. Then I have these functions to get a state by its acronym or get an acronym of a state, as their parameter is of a type generated by the array, I can guarantee there will be an entry with that parameter in the array. Still, the find function may return undefined, and I have that if to deal with that. It is already kinda type safe, there is no way someone will be able to pass a parameter that will not be in the array, I just wanted to write the code in a way that typescript understands that.

I thought about making my own for loop to find, but I would have to instantiate the variable anyway so the problem would still be there, just in a different way. I also know I can assert that the variable is not undefined, but again I would be dealing with the fact that typescript doesn't know that the state/acronym is in the array, and that is not what I want. I want to write the code in a way that typescript knows that any state/acronym will be found in the array if I loop through it.


Solution

  • TypeScript doesn't have a representation in the type system for exhaustive arrays. This is more or less the subject of microsoft/TypeScript#47404 and microsoft/TypeScript#46894. There's no way to tell TypeScript that find() will definitely succeed, because at least one of the array elements will match the predicate. It's not clear how you'd even begin to encode such things into the type system. It's outside the scope of what we can expect TypeScript to track.


    So instead you'll need to work around it. The easy and safe thing to do is to perform a redundant runtime check and then throw an exception in the impossible case:

    function getStateByAcronym(acronym: Acronym): State {
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[0] == acronym)
        if (entry == undefined) { throw new Error("OH NO") }
        return entry[1];
    }
    
    function getAcronymByState(state: State): Acronym {
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[1] == state)
        if (entry == undefined) { throw new Error("OH NO") }
        return entry[0];
    }
    

    This is a little extra work at runtime, but now both TypeScript and any casual observer will agree that if the function completes, it returns a value of the expected type.


    The easy and less safe thing to do is just assert that find()'s return value is defined by using a non-null assertion operator (!):

    function getStateByAcronym(acronym: Acronym): State {
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[0] == acronym)!
        return entry[1];
    }
    
    function getAcronymByState(state: State): Acronym {
        let entry = Constants.STATES_AND_ACRONYMS.find((entry) => entry[1] == state)!
        return entry[0];
    }
    

    This actually isn't very much different from the above runtime check, since if the impossible situation occurs, you'll get an exception thrown here too. Indexing into undefined is a TypeError.


    If you really want TypeScript to believe that every entry exists, you can refactor to encode the data in a format that TypeScript already understands as exhaustive. For example, an object type definitely contains a property at each known key:

    class Constants {
        static readonly ACRONYMS_TO_STATES = {
            AA: "NAME_A",
            BB: "NAME_B",
            CC: "NAME_C",
            DD: "NAME_D",
            EE: "NAME_E",
            FF: "NAME_F"
        } as const;
    
    }    
    type Acronym = keyof typeof Constants.ACRONYMS_TO_STATES;    
    function getStateByAcronym(acronym: Acronym): State {
        return Constants.ACRONYMS_TO_STATES[acronym]
    }
    

    That works because TypeScript knows that you're just looking something up, and it will be there. There's no chance of the lookup falling off the end of some list.

    The reverse lookup could also be done with an object, although you need something like a type assertion to convince TypeScript that flipping keys and values produces a strongly typed result. You could put that into a utility function:

    function reverse<T extends Record<keyof T, PropertyKey>>(
      obj: T
    ): { [K in keyof T as T[K]]: K } {
        return Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));
    }
    

    And then write the corresponding reverse-lookup code:

    class Constants {
        static readonly STATES_TO_ACRONYMS = reverse(Constants.ACRONYMS_TO_STATES);
    }
    type State = keyof typeof Constants.STATES_TO_ACRONYMS
    function getAcronymByState(state: State): Acronym {
        return Constants.STATES_TO_ACRONYMS[state]
    }
    

    This might be overkill for your use case, but sometimes it's better to use data structures which represent our desired invariants directly instead of indirectly.

    Playground link to code