The Payload<T>
type have fieldName
and value
properties. The fieldName
should be the property type of the CounterState
type and the value
should be the type corresponding to its field.
type PayloadAction<P = void, T extends string = string, M = never, E = never> = {
payload: P;
type: T;
} & ([M] extends [never] ? {} : {
meta: M;
}) & ([E] extends [never] ? {} : {
error: E;
})
export interface CounterState {
id: string;
status: 'idle' | 'loading' | 'failed';
email: string;
password: string;
}
type Payload<T> = {
[K in keyof T]: {
fieldName: K;
value: T[K];
};
}[keyof T];
type T0 = Payload<CounterState>;
const initialState: CounterState = {
id: '',
email: '',
password: '',
status: 'idle',
};
function reducer(state: CounterState = initialState, action: PayloadAction<Payload<CounterState>>) {
switch (action.type) {
case 'UPDATE_FIELD':
const nState = { ...state };
const { fieldName, value } = action.payload;
nState[fieldName] = value; // TSC throws error
return nState;
default:
return state;
}
}
The T0
type is correct:
type T0 = {
fieldName: "id";
value: string;
} | {
fieldName: "status";
value: "idle" | "loading" | "failed";
} | {
fieldName: "email";
value: string;
} | {
fieldName: "password";
value: string;
}
When I assign the value to state from action.payload
use nState[fieldName] = value;
, got error:
Type 'string' is not assignable to type '"idle" | "loading" | "failed"'.(2322)
TSC does not infer correct value
type for fieldName
value. Why? How can I solve this? Actually, the value
type inferred is always string
. I expect that when the fieldName
is status
, the value
type should be inferred to 'idle' | 'loading' | 'failed'
.
CodePudding user response:
You have an error because fieldName
might be a status
which is allowed to be only 'idle' | 'loading' | 'failed'
whereas you are trying to assign much wider type - string
. Try to comment status
in each object and type and error will disappear.
Your type of payload itself is correct:
type T0 = Payload<CounterState>;
but try to get a type of value
from this union:Payload<CounterState>['value']
, you will get a string. This is exactly what you are getting here: const { fieldName, value } = action.payload;
.
TypeScript is able to infer it with condition statement
:
function reducer(state: CounterState = initialState, action: PayloadAction<Payload<CounterState>>) {
switch (action.type) {
case 'UPDATE_FIELD':
const nState = { ...state };
const { fieldName, value } = action.payload;
if (fieldName === 'status') {
nState[fieldName] = value;
} else {
nState[fieldName] = value;
}
return nState;
default:
return state;
}
but is looks a bit crazy, is not it ?:D
In this case we need to trick typescript:
type PayloadAction<P = void, T extends string = string, M = never, E = never> = {
payload: P;
type: T;
} & ([M] extends [never] ? {} : {
meta: M;
}) & ([E] extends [never] ? {} : {
error: E;
})
export interface CounterState {
id: string;
status: 'idle' | 'loading' | 'failed';
email: string;
password: string;
}
type Payload<T> = {
[K in keyof T]: {
fieldName: K;
value: T[K];
};
}[keyof T];
type T0 = Payload<CounterState>;
const initialState: CounterState = {
id: '',
email: '',
password: '',
status: 'idle',
};
const setProperty = <
S extends CounterState,
Prop extends keyof S,
Value extends S[Prop]
>(state: S, prop: Prop, value: Value) => ({
...state,
[prop]: value
})
function reducer(state: CounterState = initialState, action: PayloadAction<Payload<CounterState>>) {
switch (action.type) {
case 'UPDATE_FIELD':
const { fieldName, value } = action.payload;
return setProperty(state, fieldName, value)
default:
return state;
}
}
UPDATE
But don't know why we should use this trick
Consider this example:
type Foo = {
a: 'a'
}
type Bar = {
a: 'b'
}
type Result = Foo | Bar
type Test = Result['a'] // "a" | "b"
Test
is a union of a
and b
it is expected and nothing new here.
However, if you add another one type to a union with a: string
it will change Test
.
type Foo = {
a: 'a'
}
type Bar = {
a: 'b'
}
type Baz = {
a: string
}
type Result = Foo | Bar | Baz
type Test = Result['a'] // string
Or in other words: "a" | string
evaluates to string
since string
is much wider type.
TypeScript returns the most common type. See docs here and here
In above case string
is a super type and a
is a subtype. When we have a union of subtype and supertype it is always safer to use only properties from supertype.
Consider another one example:
type A = {
a: 'a'
}
type B = {
a: 'a',
b: 'b'
}
type Result = A | B
type Test = Result['a'] // ok
type Test2 = Result['b'] // error
A
is a supertype and B
is a subtype of A
.
You are only allowed to use props from supertype because thay are always safe to obtain.
This is why in your case value
is evaluated as a string, because it is always safer to consider it as a string
. If you add condition, like I did in my first example if (fieldName === 'status')
TS will be able to infer type of value
and fieldName
, otherwise TS will stick with most safer types.