2

In my typescript program (code below) I define two base types (Player, State) and then a few nested Record types used as mappings.

I then in a typed function create an instance of one of these records based on an existing instance of the nested record.


type Player = "1" | "2";
type State = "A" | "B" | "C";
type StateMapping = Record<State, State>;
type PlayerStateMappings = Record<Player, StateMapping>
type PlayerStates = Record<Player, State>;

const playerStateMappings: PlayerStateMappings = {
    "1": {
        "A": "B",
        "B": "C",
        "C": "A"
    },
    "2": {
        "C": "B",
        "B": "A",
        "A": "C"
    },
}

function nextStates(currentState: State): PlayerStates {
    var nextStates = {};
    for(const player of Object.keys(playerStateMappings)){
        nextStates[player] = playerStateMappings[player][currentState]
    }
    return nextStates;
}

console.log(nextStates("A"))

This code throws the following type error at the return statement, since I created the object without the required keys and only added those afterwards: TS2739: Type '{}' is missing the following properties from type 'PlayerStates': 1, 2.

My question is if there is a way to avoid this type error that fulfils the following requirements:

  1. The typesystem is not relaxed in particular it still enforces that the nextStates function returns a complete and valid PlayerStates object.
  2. The nextStates object is created programatically based on the keys of the playerStatesMapping object, meaning I don't have to hardcode all of the players again.

After some research on SO I found a few options that avoid the error but all of which violate one of the two requirements mentioned above:

Approaches that violate condition 1:

  1. Make the PlayerStates type partial: type PlayerStates = Partial<Record<Player, State>>;
  2. Enforce type using as keyword: var nextStates = {} as PlayerStates; (from this question)

Approaches that violate condition 2:

  1. Set a default value for each Player in the object creation: var nextStates = {"1": "A", "2": "B"}

I know that the whole typing is a bit of an overkill in the above example but this is a highly simplified / reduced version of the problem that I encountered in a more complex project where above requirements / expectations make more sense.

PS: Coming from a python background I guess I am looking for something like a dict comprehension that allows me to initialize a new dictionary based on some iteration.

2
  • Why “Enforce type using as keyword” violate point 1? IMO it doesn’t. Commented Apr 15, 2021 at 17:48
  • 1
    @hackape: Because then the type system would not enforce that I return a complete PlayerStates Record. For example the following code would typecheck: ``` function nextStates(currentState: State): PlayerStates { var nextStates = {} as PlayerStates; return nextStates; } ``` Commented Apr 15, 2021 at 17:52

3 Answers 3

2

Based off @hackape's answer I was able to produce the following, but it required redefining Object.fromEntries and Object.entries:

type Player = "1" | "2";
type State = "A" | "B" | "C";
type StateMapping = Record<State, State>;
type PlayerStateMappings = Record<Player, StateMapping>
type PlayerStates = Record<Player, State>;

const playerStateMappings: PlayerStateMappings = {
    "1": {
        "A": "B",
        "B": "C",
        "C": "A"
    },
    "2": {
        "C": "B",
        "B": "A",
        "A": "C"
    },
}

// stricter version of Object.entries
const entries: <T extends Record<PropertyKey, unknown>>(obj: T) => Array<[keyof T, T[keyof T]]> = Object.entries

// stricter version of Object.fromEntries
const fromEntries: <K extends PropertyKey, V>(entries: Iterable<readonly [K, V]>) => Record<K, V> = Object.fromEntries

function nextStates(currentState: State): PlayerStates {
  return fromEntries(
    entries(playerStateMappings).map(([player, mapping]) =>
      [player, mapping[currentState]]
    )
  )
}

Playground link

Sign up to request clarification or add additional context in comments.

5 Comments

Or the lazy man’s as Player trick works too. I think it’s legit, cus we know it’s a fact.
Sure - though personally I think it's always better to avoid overriding the compiler where it's practical/possible
Thanks a lot guys, this does seem to work and ticks all of my boxes. Could you maybe give a hint as to the idea behind the redefinition of entries and fromEntries. I have a little difficulty wrapping my head around it (I am new to typescript).
Unfortunately I just realised that this answer does somehow seem to relax the typing system as it does not really enforce the nextStates function to return a full PlayerStates object. If I declare the PlayerStateMappings partial and remove one of the player entries from playerStateMappings the code still typechecks even though the return value of nextStates is not a valid PlayerStates object anymore.
Interestingly in the Typescript Playground above described change does throw an error but when I run the file using ts-node it typechecks without error. (Even though nextStates will return an invalid PlayerState object)
1

Try the functional programming way. You can avoid using the nextStates variable altogether. Problem gone.

function nextStates(currentState: State): PlayerStates {
  return Object.fromEntries(
    Object.entries(playerStateMappings).map(([player as Player, mapping]) =>
      [player, mapping[currentState]]
    )
  )
}

6 Comments

This wouldn't compile for me. I got an error saying: Type '{ [k: string]: State; }' is missing the following properties from type 'PlayerStates': 1, 2
I just type it out on a cellphone. Can you share a TS playground? It’s hard for me to setup.
Ah I see. It’s due to the TS stock typing of standard lib is actually kinda conservative. Object.entries<T> COULD infer entry[0] to be keyof T but it doesn’t, instead it just infer string which is playing safe.
That should be easy to fix, let me try it
|
0

The type error is from your nextStates variable as you haven't specified it's type so it's inferred as {} when you define it as the empty object.

You can use a Partial type only for your nextStates variable to allow it to start off empty, but you will need a way to tell the compiler when the full PlayerStates object has been created using a guard. Here's an example:

type Player = "1" | "2";
type State = "A" | "B" | "C";
type StateMapping = Record<State, State>;
type PlayerStateMappings = Record<Player, StateMapping>
type PlayerStates = Record<Player, State>;

const playerStateMappings: PlayerStateMappings = {
    "1": {
        "A": "B",
        "B": "C",
        "C": "A"
    },
    "2": {
        "C": "B",
        "B": "A",
        "A": "C"
    },
}

// Stricter version of keys which returns Array<keyof T> instead of string[]
const keys = <T extends Record<PropertyKey, unknown>>(obj: T): Array<keyof T> =>
  Object.keys(obj);

// Type guard to convert Partial<PlayerStates> to PlayerStates
const isCompletePlayerStatesObj = (
  obj: Partial<PlayerStates>
): obj is PlayerStates => 
  obj["1"] !== undefined && obj["2"] !== undefined;

function nextStates(currentState: State): PlayerStates {
    var nextStates: Partial<PlayerStates> = {};

    for(const player of keys(playerStateMappings)){
        nextStates[player] = playerStateMappings[player][currentState]
    }

    if (!isCompletePlayerStatesObj(nextStates)) {
      throw new Error("Whoops this function was badly implemented")
    }

    return nextStates;
}

console.log(nextStates("A"));

5 Comments

Thanks, for the suggestion. I would like to avoid having to hard code / list all of the players again one by one as you did in the function you added (I probably should have formulated requirement 2 better). Also I am using the typing system here to catch more errors at compile / typecheck time. As I understand your solution would relax the typing system and replace it with a runtime check, which is not really ideal.
I don't think it's possible then. The best option IMO would be to have an initial PlayerStates object that you can use as a starting point instead of an empty object.
Yeah, that is the way I have currently implemented it. It just feels very verbose / suboptimal. Essentially I want to have the list of players in only one place since these base types contain many more values that will probably frequently change, which is why I want to avoid writing them all out again.
I agree it's verbose. At least the compiler will complain to you if you are missing a player in the initial PlayerStates object. So as soon as you add a player the compiler will tell you that you need to update that object.
I've added a new answer based on @hackape's suggestion which works :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.