TypeScript dependent string literal properties and indexing

Similar to Typescript: Type of a property dependent on another property within the same object I want a type where the properties are dependent.

const attributes = {
    physical: {
        traits: {
            strength: 1,
            dexterity: 1,
            stamina: 1,
        }
    },
    social: {
        traits: {
            charisma: 1,
            manipulation: 1,
            appearance: 1,
        }
    },
    mental: {
        traits: {
            perception: 1,
            intelligence: 1,
            wits: 1,
        }
    }
};

type AttributeTrait =
    | {
        category: 'physical';
        trait: keyof typeof attributes.physical.traits;
    }
    | {
        category: 'social';
        trait: keyof typeof attributes.social.traits;
    }
    | {
        category: 'mental';
        trait: keyof typeof attributes.mental.traits;
    };

const action: AttributeTrait = {
    category: 'social',
    trait: 'manipulation'
}

function increment(action: AttributeTrait) {
    attributes[action.category].traits[action.trait]++; // error 7053
}

In the function, action.trait is typed as:

(property) trait: "strength" | "dexterity" | "stamina" | "charisma" | "manipulation" | "appearance" | "perception" | "intelligence" | "wits"

so it can't be used to index traits.

How can I solve this?


Solution 1:

I don't have a non-redundant and type safe solution for you. Your AttributeTrait is something I've been calling a correlated record type. It's a discriminated union where some bit of code is safe for each member of the union, but the compiler cannot see that it safe for the union as a whole, because it loses track of the correlation between the category and trait properties.

If you write redundant code, the error goes away:

function incrementRedundant(action: AttributeTrait) {
    switch (action.category) {
        case "physical":
            attributes[action.category].traits[action.trait]++;
            return;
        case "social":
            attributes[action.category].traits[action.trait]++;
            return;
        case "mental":
            attributes[action.category].traits[action.trait]++;
            return;
    }
}

But try as you might, you can't collapse those cases into a single line of code and get the compiler to verify safety for you. That's why I filed microsoft/TypeScript#30581, and one reason I had filed microsoft/TypeScript#25051. Since you can't ask the compiler to treat the single line as if it had been written out in a switch/case statement, the best thing I can think of is to use a type assertion to tell the compiler that you know better than it does.

One way to do this is to lie a little bit and tell the compiler that attributes actually has all traits on all category objects:

function increment(action: AttributeTrait) {
    (attributes as
        Record<AttributeTrait["category"], {
            traits: Record<AttributeTrait["trait"], number>
        }>
    )[action.category].traits[action.trait]++;
}

This is less type safe than the redundant code, but at least it lets you move forward.


Okay, hope that helps; good luck!

Playground link to code