Definitelytyped: [@types/react] cannot setState with dynamic key name type-safe

Created on 18 Jun 2018  ·  12Comments  ·  Source: DefinitelyTyped/DefinitelyTyped

If you know how to fix the issue, make a pull request instead.

  • [x] I tried using the @types/react package and had problems.
  • [x] I tried using the latest stable version of tsc. https://www.npmjs.com/package/typescript
  • [x] I have a question that is inappropriate for StackOverflow. (Please ask any appropriate questions there).
  • [x] [Mention](https://github.com/blog/821-mention-somebody-they-re-notified) the authors (see Definitions by: in index.d.ts) so they can respond.

    • Authors: @johnnyreilly, @bbenezech, @pzavolinsky, @digiguru, @ericanderson, @morcerf, @tkrotoff, @DovydasNavickas, @onigoetz, @theruther4d, @guilhermehubner, @ferdaber, @jrakotoharisoa

If you do not mention the authors the issue will be ignored.

I cannot call setState with an object being created from computed property name with type-safety:

type State = {
  username: string,
  password: string
};

type StateKeys = keyof State;

class A extends React.Component<{}, State> {
  dynSetState(key: StateKeys, value: string) {
    this.setState({
      [key]: value // Error here. Pretty sure key is in StateKeys
    });
  }
}

I do aware of #18365, and the workaround in https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351868649 . However, when using the workaround, Typescript doesn't error out when it should:

  dynLooselySetState(key: string, value: string) {
    this.setState(prevState => ({
      ...prevState,
      [key]: value // No error here, but can't ensure that key is in StateKeys
    }));
  }

Most helpful comment

This is a limitation of the compiler itself, inferred types via computed property keys do not support union types, it only supports string, number, symbol or a single literal of those 3, if it detects that it's a union of any of those it will just coerce it into the general string type. The workaround here would be to coerce the object:

dynSetState(key: StateKeys, value: string) {
  this.setState({
    [key]: value
  } as Pick<State, keyof State>)
}

This will still give an error appropriately if the value is not in the set of possible property value types, but will not give you an error if the keys are not within the set of possible property keys.

All 12 comments

This is a limitation of the compiler itself, inferred types via computed property keys do not support union types, it only supports string, number, symbol or a single literal of those 3, if it detects that it's a union of any of those it will just coerce it into the general string type. The workaround here would be to coerce the object:

dynSetState(key: StateKeys, value: string) {
  this.setState({
    [key]: value
  } as Pick<State, keyof State>)
}

This will still give an error appropriately if the value is not in the set of possible property value types, but will not give you an error if the keys are not within the set of possible property keys.

exactly what @ferdaber although IMHO casting stuff like this is not very "good pattern", overall you should prefer updating state via callback pattern which again adheres to best practices, like extracting that callback outside the class to pure easy to test function :)

Good:

class C extends Component<{}, State> {
  updateState(key: StateKeys, value: string) {
    this.setState((prevState) => ({
      ...prevState,
      [key]: value,
    }));
  }
}

Better:

const updateState = <T extends string>(key: keyof State, value: T) => (
  prevState: State
): State => ({
  ...prevState,
  [key]: value
})

class C extends Component<{}, State> {
  doSomething(){
    this.setState(updateState('password','123124'))
  }
}

I have not found related issue about this limitation in typescript repo.. Any chance you know if this has been discussed (and where) in typescript repo? Basically wondering if there are some plans to address this in future typescript versions.
Thanks!

Here is the discussion, the design notes from the TS team, and an attempt at fixing it (which I believe was retracted for a future version):
https://github.com/Microsoft/TypeScript/issues/13948
https://github.com/Microsoft/TypeScript/issues/18155
https://github.com/Microsoft/TypeScript/pull/21070

Indeed, what I'm trying to accomplish is to do something like this: https://reactjs.org/docs/forms.html#handling-multiple-inputs
Which will make it easier to deal with a form with multiple inputs. I may still have to make sure that the "name" attribute is what we expect, but after that type-safety should works.

I had to add as unknown to @ferdaber's solution:

  this.setState({
    [key]: value
  } as unknown as Pick<State, keyof State>)

That warned if key was a boolean, but not if it was a number!

So I went for this shorter solution:

  this.setState<never>({
    [key]: value
  })

This warns if key is a boolean or a number.

Why is https://github.com/DefinitelyTyped/DefinitelyTyped/issues/26635#issuecomment-400260278 working? I mean the key is still a union type?

May be this will help you

type IAction = {
  [P in keyof IAppSettings]?: IAppSettings[P];
};

function reducer(state: IAppSettings, action: IAction) {
  return {
    ...state,
    ...action,
  };
}

Is there any progress in this issue?

I was able to get this working

handleTextChange(name: keyof State, value: any) {
    this.setState({
        ...this.state,
        [name]: value
    });
}

For others looking at this and trying to apply it to their own state types, note that the type of value in the original post and responses is only string because all property types in State are string. It might be better to avoid using any or additional casting when other types are involved, and instead restricting the types such that the type of value corresponds to its type matching the key type.

interface State {
  name: string;
  age: number;
}
type StateKeys = keyof State;
function dynSetState<K extends StateKeys>(key: K, value: State[K]) {
  this.setState({ [key]: value }); // fails; if only this worked...
  this.setState({ [key]: value } as Pick<State, K>); // clean cast works
  this.setState((s, _) => ({ ...s, [key]: value })); // avoids cast, but changes js
}
dynSetState("name", "a"); // works as expected
dynSetState("name", 1); // fails as expected
dynSetState("age", "a"); // fails as expected
dynSetState("age", 1); // works as expected

Why is #26635 (comment) working? I mean the key is still a union type?

It works because the type of { ...prevState } will sufficiently match State, basically because we have at least all the properties of state. It might help to understand why it _doesn't work_ without it. The root of the issue is that the type of the object created: { [key: K]: State[K] } is not specific enough; it is { [x: string]: State[K] } which can't cast the key string to a keyof State.

Just adding to @arinwt as an another example.

TL;DR: Final Solution At Bottom

Expanded Solution With Console Errors:

type RegisterTypes = {
    email: string;
    password: string;
}

// ...

const [state, setState] = useState<RegisterTypes>({
    email: "",
    password: "",
});

// ...

const onChangeInput = (key: keyof RegisterTypes) => (event: React.ChangeEvent<HTMLInputElement>) => {
    setState({
        [key]: event.target.value
    } as Pick<RegisterTypes, typeof key>);
};

// ...

<input type="email" onChange={onChangeInput('email')} value={state.email} />

Although this gives me the following error in the console:

Warning: A component is changing a controlled input of type password to be uncontrolled. Input elements should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component.

Alternative Solution (Cheating) With Console Errors:

Also gives the warning too.

type RegisterTypes = {
    [key: string]: string;
}

// ...

const onChangeInput = (key: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
    setState({
        [key]: event.target.value
    });
};

Final Solution No Errors:

type RegisterTypes = {
    email: string;
    password: string;
}

// ...

const onChangeInput = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newState = { ...state };
    newState[event.target.name as keyof RegisterTypes] = event.target.value;
    setState(newState);
};

// ...

<input name="email" type="email" onChange={onChangeInput} value={state.email} />
Was this page helpful?
0 / 5 - 0 ratings