Home > Enterprise >  How to make an object property depend on another one in a generic type?
How to make an object property depend on another one in a generic type?

Time:12-01

I'm trying to make an object with a property that depends of another property.

This is a very simplified example of what i tried so far.
I expected T to be infered from name. value should then be limited to the valid value in TypeA.

type TypeA = {
  some: 'some2';
  thing: 'thing2';
};

type TypeAUnion = keyof TypeA;

type TestType<T extends TypeAUnion = TypeAUnion> = {
  name: T;
  value: TypeA[T];
};

const test1: TestType = {
  name: 'some',
  value: 'some2',
};

const test2: TestType = {
  name: 'some',
  value: 'thing2', // shouldn't be allowed here
};

EDIT:

A better example of what i'm trying to do.

type StateType = {
  thingA: string;
  thingB: number;
};

type StateKeysUnion = keyof StateType;

const state: StateType = {
  thingA: 'somestring',
  thingB: 10,
};

type PayloadType<T extends StateKeysUnion = StateKeysUnion> = {
  key: T;
  value: StateType[T];
};

const setThing = (payload: PayloadType) => {
  state[payload.key] = payload.value;
};

setThing({
  key: 'thingA',
  // expected to only accept string
  value: true,
});


setThing({
  key: 'thingB',
  // expected to only accept number
  value: 'asdas',
});

CodePudding user response:

What you're going for can't be expressed using a generic type.

type Narrow<T extends string = string> = T;

// str type narrowing
type Out = Narrow<"hi">;
// type Out = "hi"

// default case
type Out2 = Narrow;
// type Out2 = string

// **values don't narrow the type**
let t: Narrow = "hi"
type Out3 = typeof t;
// type Out3 = string (default)

If you were to specify some in the generic field

const test2: TestType<"some"> = {
  name: 'some',
  value: 'thing2', // it now gives the error you're looking for.
};

But because you aren't, T defaults to keyof TypeA, and allows you to mix-and-match.

You should instead use @jcalz union approach. :)

CodePudding user response:

It looks like you want to take your original StateType type

type StateType = {
    thingA: string;
    thingB: number;
};

and make PayloadType a union of key/value pairs for each property in keyof StateType:

type PayloadType = {
    key: "thingA";
    value: string;
} | {
    key: "thingB";
    value: number;
}    

This will only let you assign the correct values and will complain if you mismatch key and value:

let payloadType: PayloadType;
payloadType = { key: "thingA", value: "abc" }; // okay
payloadType = { key: "thingA", value: 123 }; // error, number is not string
payloadType = { key: "thingB", value: 123 }; // okay

You could define PayloadType programmatically by writing it as a distributive object type, which is a mapped type over each property, into which we immediately index with the union of keys to get the union of object types we care about:

type PayloadType = { [P in keyof StateType]: {
    key: P;
    value: StateType[P];
} }[keyof StateType];

Your further example looks like this:

const state: StateType = {
    thingA: 'somestring',
    thingB: 10,
};

const setThing = (payload: PayloadType) => {
    state[payload.key] = payload.value; // error!
    //^^^^^^^^^^^^^^^^ <-- string | number not assignable to never
};

Even with PayloadType being correct, the compiler complains about state[payload.key] = payload.value. The right hand side of the assignment is string | number, but the left hand side is required to be string & number because of microsoft/TypeScript#30769. TypeScript doesn't have direct support for correlated unions, as described in microsoft/TypeScript#30581. It sees payload.key being of type "thingA" | "thingB" and payload.value as being of type string | number, and it gets worried that you might be, say, assigning a string to state.thingA. It can't see that this is impossible.

The recommended way to deal with this is to make setThing() generic in a particular way as described in microsoft/TypeScript#47109.

First we make PayloadType generic again, but still as a distributive object type. If you just write PayloadType by itself or PayloadType<keyof StateType>, it's the full union. But if you write PayloadType<K1> for some particular property K1 (like "thingA"), you get just the corresponding element of the union:

type PayloadType<K extends keyof StateType = keyof StateType> = { [P in K]: {
    key: P;
    value: StateType[P];
} }[K];

And now setThing()'s implementation works:

const setThing = <K extends keyof StateType>(payload: PayloadType<K>) => {
    state[payload.key] = payload.value; // okay
};

That's because both sides of the assignment are of type StateType[K].


And let's verify that setThing() works as desired from the caller's side as well:

setThing({ key: "thingA", value: "abc" }); // okay
// const setThing: <"thingA">(payload: { key: "thingA"; value: string; }) => void

setThing({ key: "thingA", value: 123 }); // error!
// ---------------------> ~~~~~

setThing({ key: "thingB", value: 123 }); // okay
// const setThing: <"thingB">(payload: { key: "thingB"; value: number; }) => void

Looks good. The compiler infers K as being "thingA" or "thingB" depending on the key property, and then checks value against it.

Playground link to code

  • Related