TypeScript: Computed property key names should not be widened

TypeScript Version: 2.1.5

Code The latest @types/react (v15.0.6) use Pick<S,K> to correctly type the setState method of React.Components. While this makes it now possible to merge the state of a component instead of replacing it, it also makes it harder to write a dynamic update function that uses computed properties.

import * as React from 'react';

interface Person {
  name: string;
  age: number|undefined;
}

export default class PersonComponent extends React.Component<void, Person> {
  constructor(props:any) {
    super(props);

    this.state = { 
      name: '',
      age: undefined
    };
    this.handleUpdate = this.handleUpdate.bind(this);
  }

  handleUpdate (e:React.SyntheticEvent<HTMLInputElement>) {
    const key = e.currentTarget.name as keyof Person;
    const value = e.currentTarget.value;
    this.setState({ [key]: value }); // <-- Error
  }

  render() {
    return (
      <form>
        <input type="text" name="name" value={this.state.name} onChange={this.handleUpdate} />
        <input type="text" name="age" value={this.state.age} onChange={this.handleUpdate} />
      </form>
    );
  }
}

The above should show an actual use case of the issue, but it can be reduced to:

const key = 'name';
const value = 'Bob';
const o:Pick<Person, 'name'|'age'> = { [key]: value };

which will result in the same error. Link to the TS playground

Expected behavior: No error, because key is a keyof Person, which will result in the literal type "name" | "age". Both values that are valid keys forstate.

Actual behavior: The compiler will throw the following error:

[ts] Argument of type '{ [x: string]: string; }' is not assignable 
     to parameter of type 'Pick<Person, "name" | "age">'.
       Property 'name' is missing in type '{ [x: string]: string; }'.

My uninformed guess is that the constant key is (incorrectly) widened to string.

About this issue

  • Original URL
  • State: open
  • Created 7 years ago
  • Reactions: 113
  • Comments: 34 (13 by maintainers)

Most upvoted comments

A small note here, that @lucasleong solution introduces a possible runtime bug (and a very subtle one too) in React, since State updates may be asynchronous.

You should never use this.state to calculate the new state using the this.setState({ ... }) syntax! The more correct solution would be using an updater function for the state instead, so we can make sure we always spread the correct up-to-date state into the new state:

private handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    this.setState((prevState) => ({
        ...prevState,
        [e.target.name]: e.target.value
    }));
}

I think there is actually a better method to type this right now using mapped types, which doesn’t even change any runtime behavior (as the state spread version does):

private handleChange = <T extends keyof IState>(event: React.ChangeEvent<HTMLInputElement>) => {
  const newState = {
    [event.target.name]: event.target.value,
    // keyNotInState: '42', -> would throw a compile time error
    // numericKeyInState: 'assigning wrong type' -> would throw a compile time error
  };
  this.setState(newState as { [P in T]: IState[P]; });
}

That way the compiler is even able to catch if you are accidentally trying to assign wrong keys explicitly in the newState object, which the spread previous state method would also silently allow (since the whole required state is already in the object, via the spread).

@AlbertoAbruzzo I solved this by first expanding the current state, using the spread operator. Then apply computed property name to update the state like normal. This method seems to work without giving an error.

For example, using @oszbart’s example:

private handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    this.setState({
        ...this.state,
        [e.target.name]: e.target.value
    });
}

This solution has a possible runtime bug, see @timroes solution for a workaround!

Now that TypeScript supports pattern template literal index signatures, this widening to string is even more undesirable because there doesn’t seem to be a way to use such template-pattern keys directly in an object literal:

function foo(str: string) {
  const key = `prefix_${str}` as const;
  // const key: `prefix_${string}`
  const obj = { [key]: 123 }
  // const obj: { [x: string]: number; } 😢
  // wanted const obj: { [x: `prefix_${string}]: number; }
}

Playground link

FWIW this is the helper function I tend to use when I need stronger types for computed keys:

function kv<K extends PropertyKey, V>(k: K, v: V): { [P in K]: { [Q in P]: V } }[K] {
	return { [k]: v } as any
}

Which produces these:

const test = kv("a", 123)
// const test: { a: number; }

const test2 = kv(Math.random() < 0.5 ? "x" : "y", "abc");
// const test2: { x: string; } | { y: string; }

const test3 = {
	a: 1,
	b: "two",
	...kv(Math.random() < 0.5 ? "a" : "b", true)
}
// const test3: { a: boolean; b: string; } | { b: boolean; a: number; }

function f(str: string) {
	const test4 = kv(`a${str}b`, new Date());
	// const test4: { [x: `a${string}b`]: Date; }
}

Playground link to code

@sebald You might mean something like

let peter: Pick<Partial<Person>, 'name' | 'age'> = {
    [key]: value
};

In any case, I don’t think key should widen, so there looks like a bug here.

I’ve encountered similar problem…

interface ILoginState {
	username: string;
	password: string;
	message: string;
	isError: boolean;
	isLoaded: boolean;
}

// in a class component
//...
handleChange = (key1: keyof ILoginState) => (e: any) => {
  // Error: property 'password' is missing in type '{ [x: string]: any }'
  this.setState({ [key1]: e.target.value });
};

I’m not quite sure if this is (not?) part of the bug, but my scenario looks as follows:

enum EnumDefinition {
    NAME = "name"
}

interface State {
    [EnumDefinition.NAME]: string
    test?: string
}

class ComponentName extends React.Component<Props, State> {
    
    public render() {
        <input name={EnumDefinition.NAME} onChange={this.handleChange} />
    }

    private handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
        this.setState({[event.target.name]: event.target.value});
    }
}

Which ends up with something similar to: Type '{ [x: string]: string; }' is not assignable to type 'Pick<State, EnumDefinition.NAME | "test". Property 'name' is missing in type '{ [x: string]: string; }'.

Before 2.9 this hack this.setState({[event.target.name as any]: event.target.value}); helped to get rid of errors, but now it doesn’t help unfortunately.

Problem:

‪type S = "a" | "b";‬

‪interface If {‬
‪    [_ in S]: Type‬
‪}‬

Workaround:

‪```‬ts ‪interface If {‬ ‪ a: Type;‬ ‪ b: Type;‬ ‪}‬

‪type S = keyof If;‬ ‪```

See this tweet.

Fix is up at #21070

Does not work anymore with 2.9.1:

this.setState({ [key as any]: value })

I have to ts-ignore it now:

// @ts-ignore
this.setState({ [key]: value });

Any better solution with 2.9.1?

How about this? (SO)

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

That tells setState that it should expect no properties in particular. And then we pass an extra property, which it doesn’t seem to mind.

Edit: Yeah this doesn’t check the types, or fix this issue. It’s just a quick workaround for anyone who is stuck transpiling, and came here for help.

The string literal change was a nice stopgap but definitely falls short. It would be nice to see this work with unique symbol types as well, since you actually must use computed keys.

const Key = Symbol();
type Thing = {} | {
	[Key]: string;
	property: string;
};

declare const thing: Thing;
const test1 = 'property' in thing ? thing['property'] : undefined; // this is fine
const test2 = Key in thing ? thing[Key] : undefined; // this is an error
  this.setState<never>({
    [key]: value
  })

This wouldn’t verify that value is of the right type, right?

I think this may be related. I’m seeing a problem with this in V3.1.1:


import React from 'react';

interface Props<T> {
    data: T;
}

interface State<T> {
    a: number;
    b: number;
    data: T;
}

const f = (): { a: number } | {} => ({});

export class Table<T> extends React.Component<Props<T>, State<T>> {
    constructor(props: Props<T>) {
        super(props);
        this.state = { a: 1, b: 2, data: props.data };
    }

    public update() {
        this.setState(f()); // << error here
    }
}

Error text:


Argument of type '{} | { a: number; }' is not assignable to parameter of type 'State<T> | ((prevState: Readonly<State<T>>, props: Props<T>) => State<T> | Pick<State<T>, "a"> | null) | Pick<State<T>, "a"> | null'.
  Type '{}' is not assignable to type 'State<T> | ((prevState: Readonly<State<T>>, props: Props<T>) => State<T> | Pick<State<T>, "a"> | null) | Pick<State<T>, "a"> | null'.
    Type '{}' is not assignable to type 'Pick<State<T>, "a">'.
      Property 'a' is missing in type '{}'.

@tkrotoff This works for me:

const o = {} as { name, age }
o[key] = value
this.setState(o)

The workaround found here worked for me

this.setState({ [key as any]: value });