How to properly type define event.target.value?

Viewed 1053

I'm authoring a node package but I'm having bit of an issue with my definitions. To be more specific I find the definition of event.target.value super confusing

Issue description:

I have the following event handler:

import { ChangeEvent, useState } from 'react' 

type FieldEvent = ChangeEvent<HTMLInputElement | HTMLTextAreaElement>

export const useField = <T>(input: T) => {
  const [value, setValue] = useState<T>(input)

  const handleChange = (event: FieldEvent) => {
    const { name, value: eventValue } = event.target
    // @ts-expect-error
    setValue(eventValue)
  }

  return [input, handleChange]
}

The expression setValue(eventValue) results in the following error:

Argument of type 'string' is not assignable to parameter of type 'SetStateAction<T>'.

I was a bit surprised by this, given a lot of exported components use different event.target.value. Eg date-picker return Date type, select Object, etc.

Issue investigation

Naturally I went to check the imported ChangeEvent react exports to see if it has correct definitions, but this appears to be correct

interface ChangeEvent<T = Element> extends SyntheticEvent<T> {
    target: EventTarget & T;
}

so according to this definition it should inherit the type of the Element that was passed to the SyntheticEvent

so I followed the chain to the HTMLInputElement declaration located in node_modules/typescript/lib/lib.dom.d.ts which is where the crux of the issue lies

interface HTMLInputElement extends HTMLElement {
  value: string
  //... rest
}

I checked back and it appears all the native <input> elements default to string as their value type, which I guess make sense.

Solving the issue

Obviously this is not ideal, given this does not represent the event.target.value behavior in a lot of the projects that use third-party-packages (which my package is supposed to support). Consider the following codesandbox

Edit Passed event value

The returned event.target.value is as you'd expect of typeof number

enter image description here

that leads me to the question, should I simply override the ChangeEvent with the following definition?

ChangeEvent<{ value: T, name: string } & HTMLInputElement>

or would this be considered a bad practice? Or is there some better way to go about doing this altogether?

2 Answers

handleChange is not match to required params.

I've tried and it worked:

export default function App() {
  const [selected, setSelected] = useState(1);
  const handleChange = (e: ChangeEvent<{
    name?: string | undefined,
    value: unknown | number
  }>, child: React.ReactNode) => {
    setSelected(e.target.value as number);
  };

  return (
    <Select value={selected} onChange={handleChange}>
      <MenuItem value={1}>One</MenuItem>
      <MenuItem value={2}>Two</MenuItem>
      <MenuItem value={3}>Three</MenuItem>
    </Select>
  );
}

Alright, I'm not 100% sure if this is the correct approach but it seems to work fine for my use-case, albeit the typing seems a tiny bit odd, but basically I'm overwriting the passed type argument to ChangeEvent and extending it by a union of one the HTML elements.

export type FieldEvent<T> = ChangeEvent<
 { value: T, name?: string } &
 (HTMLInputElement | HtmlTextAreaElement | HTMLSelectElement)
>

This overwrites the type definition of the ChangeEvent, then you just need to create a handler function that extends the type argument

export type FieldHanderFunction<T> = (event: FieldEvent<T>) => void

so then inside my hook, it basically comes down to:

const useField<T> = (input: T) => {
 const handleChange = (event: FieldEvent<T>) => {
  // ...
 }
}
Related