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.