Petr Tcoi
Petr Tcoi

Reputation: 53

Typescript. Define generic <T> keys as string

I want to get some object and modify it by keys as strings and return object.

const useInputs = <T extends Record<string, string>>(defaultValue: T) => {
    const [inputsData, setInputsData] = useState<T>(defaultValue)

    type UpdateFunction = (key: string) => (el: React.FocusEvent<HTMLInputElement>) => void
    const updateData: UpdateFunction = key => el => {
        const newInputsData = {...inputsData}
        newInputsData[key] = el.target.value
        setInputsData(newInputsData)
    }

    return { inputsData, updateData }
}

but I have error on newInputsData[key] = el.target.value "Type 'string' cannot be used to index type 'T'.ts(2536)"

How I can fix this? Thank you

Upvotes: 2

Views: 1209

Answers (1)

Aluan Haddad
Aluan Haddad

Reputation: 31863

The error comes about because the generic type T may be instantiated with any type that extends Record<string, string>.

Consider the type

interface Inputs {x: string; y: string} 

While Inputs extends Record<string, string>, it would be completely unsafe to allow indexing into it with arbitrary strings.

In other words, we wouldn't want the following

const i: Inputs = {x: "A", y: "B"};
const [inputs, setInputs] = useInputs(i);
setInputs("name")(event);

because Inputs doesn't have a name property.

Therefore, your inner type, UpdateFunction, must take an argument that is a valid key of the whatever type T is instantiated with for any instantiation. Such a type is written keyof T as in (key: keyof T) => (el: React.FocusEvent<HTMLInputElement>) => void.

Putting it together it should look like this

const useInputs = <T extends Record<string, string>>(defaultValue: T) => {
    const [inputsData, setInputsData] = useState<T>(defaultValue);

    type UpdateFunction = (key: keyof T) => (el: {target: {value: T[keyof T]}}) => void;
    const updateData: UpdateFunction = key => el => {
        const newInputsData = {...inputsData};
        newInputsData[key] = el.target.value;
        setInputsData(newInputsData)
    };

    return [inputsData, updateData] as const;
};

Note the adjustment in the return value of the outer function which allows for the conventional use of your hook.

const [inputs, setInputs] = useInputs(...);

as opposed to the unorthodox

const {inputsData, updateData} = useInputs(...);

Further, note that in order to make the above typecheck, we've also had to adjust the type of the second parameter, el, which provides the value to update the property with from React.FocusEvent<HTMLInputElement> to {target: {value: T[keyof T]}} for the same reason discussed above, the generic T may be instantiated with properties whose values are subtypes of string such as "despair" and "elation".

Alternatives include using a more precise type for el such as

Omit<React.FocusEvent<HTMLInputElement>, "target"> & {
  target: { value: T[keyof T] };
}

Moving changing how useInput is generic, from the type of the entire input object, to the just the property keys of that object.

const useInputs = <K extends string>(defaultValue: Record<K, string>) => {
  const [inputsData, setInputsData] = useState(defaultValue);

  type UpdateFunction = (
    key: K
  ) => (el: React.FocusEvent<HTMLInputElement>) => void;
  const updateData: UpdateFunction = key => el => {
    const newInputsData = { ...inputsData };
    newInputsData[key] = el.target.value;
    setInputsData(newInputsData);
  };

  return [inputsData, updateData] as const;
};

I prefer this last, because HTMLInputElement's value is always a string.

Upvotes: 2

Related Questions