silviogutierrez
silviogutierrez

Reputation: 41

Generic keyof constraints: must be a key of specific type of object

I'm not sure this is possible, but I'm pretty close.

If I have this object/shape:

export const initialState: State = {
    foods: {
        filter: '',
        someForm: {
            name: '',
            age: 2,
        },
        someFormWithoutAnAge: {
            name: '',
        },
    }
};

declare function pickAForm<R extends keyof State, S extends keyof State[R]>(key1: R, key2: S): void;

And that function works well in that I can call pickAForm("foods", "someForm") with type safety and get an error if I do pickAForm("foods", "somePropertyThatDoesntExist")

However, I'd like to add additional safety in that you can only pick items that have a certain shape. Example: someForm should work but someFormWithoutAnAge should fail because whatever you pick must have an age property. Something like so:

declare function pickAFormWithAge<R extends keyof State, S extends keyof State[R], T extends State[R][S] & {age: number}>(key1: R, key2: S): void;

But I'm not at all sure how to go about it. To summarize:

pickAFormWithAge('foods', 'someForm') // Passes
pickAFormWithAge('foods', 'someFormWithoutAge') // Should fail, does not look like {age: number}
pickAFormWithAge('foods', 'blah') // Should fail, not a key

Upvotes: 4

Views: 2382

Answers (1)

cspotcode
cspotcode

Reputation: 1892

The only way I was able to do this is by:

a. constraining the data structure to match the string literals, not the other way around.
b. passing the data structure as a function argument.

const state = {
    foods: {
        filter: '',
        someForm: {
            name: 'Some form',
            age: 2
        },
        someFormWithoutAnAge: {
            name: 'other form',
            priority: 10
        }
    }
};

interface HasAge { age: number }

// IMPLEMENTATION
function getForm<O extends {[P in P1]: {[P in P2]: HasAge}}, P1 extends string, P2 extends string>(o: O, p1: P1, p2: P2) {
    return (o[p1] as any)[p2] as any;
}

// USAGE
const form1 = getForm(state, 'foods', 'someForm'); // GOOD
const form2 = getForm(state, 'foods', 'someFormWithoutAnAge'); // ERROR
const form3 = getForm(state, 'foods', 'blah'); // ERROR

A much simpler and more flexible solution is to use regular code. pickAForm takes a function instead of string literals.

const form1 = pickAForm((state) => state.foods.someForm);
// ...or...
const form1 = pickAForm(() => initialState.foods.someForm);

Upvotes: 3

Related Questions