Home > Software engineering >  Assign field value to an object does not infer value type corresponding its field
Assign field value to an object does not infer value type corresponding its field

Time:11-25

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'.

TypeScript Playground

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;
  }
}

Playground


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.

  • Related