Here is my code:
import classNames from 'classnames';
import { uniqueId } from 'lodash';
import React, { forwardRef, useCallback, useMemo } from 'react';
interface BaseInputProps {
className?: string;
placeholder?: string;
label?: string;
suffix?: string;
disabled?: boolean;
autoFocus?: boolean;
}
type TextInputProps = {
type?: 'search' | 'text';
value?: string;
} & BaseInputProps &
OnChangeProps<string>;
type NumberInputProps = {
type: 'number';
value?: number;
} & BaseInputProps &
OnChangeProps<number>;
type OnChangeProps<T> =
| {
isForwarded: true;
onChange?: (event: Event) => void;
}
| {
isForwarded?: false;
onChange?: (value: T) => void;
};
const Input = forwardRef<HTMLInputElement, TextInputProps | NumberInputProps>(
(
{
className,
isForwarded,
placeholder,
label,
suffix,
disabled,
autoFocus,
...props
},
ref
) => {
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement> | Event) => {
if (isForwarded) {
props.onChange?.(event as Event);
} else if (props.type === 'number') {
props.onChange?.(event.target.valueAsNumber);
} else {
props.onChange?.(event.target.value);
}
},
[props.onChange, props.type]
);
const htmlId = useMemo(() => uniqueId('input_'), []);
return (
<div
className={classNames(className, 'flex flex-col items-stretch gap-1')}
>
{label && (
<label
htmlFor={htmlId}
className="mr-2 select-none font-medium text-sm text-carbon-800"
>
{label}
</label>
)}
<div className="flex flex-row items-center h-9 px-2 border-[1.5px] border-gray-200 rounded-md overflow-hidden hover:border-gray-300 focus-within:border-gray-300">
<input
id={htmlId}
ref={ref}
onChange={handleOnChange}
type={props.type}
value={props.value}
placeholder={placeholder || ''}
className={classNames(
className
?.split(' ')
.filter((c) => c.includes('bg-') || c.includes('text-'))
.join(' '),
'inline-block border-none w-full p-0 text-sm placeholder:text-gray-400 focus:ring-0'
)}
disabled={disabled}
autoFocus={autoFocus}
/>
{suffix && (
<span className="text-sm ml-2 text-carbon-600">{suffix}</span>
)}
</div>
</div>
);
}
);
Input.displayName = 'Input';
export default Input;
The component above needs the event to be of type Event
hence the weird conditional typing.
I can't understand why
props.onChange?.(event as Event);
is triggering the following error:
- Argument of type 'Event' is not assignable to parameter of type 'never'. [2345]
Do you have any clue?
You're hoping that by checking isForwarded
and props.type
, TypeScript will narrow props.onChange
to be a function expecting an appropriate argument type. But this isn't happening; props.onChange
stays a union of functions, which therefore can only safely be called with an intersection of argument types (see the support for calling unions of functions), and since string
and number
have no inhabitants in common, you get the never
type. Oops.
The first argument to forwardRef()
is a discriminated union type, and if you kept it as such, it would work to check its discriminant properties:
const Input = forwardRef<HTMLInputElement, TextInputProps | NumberInputProps>((
arg, ref) => {
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement> | Event) => {
if (arg.isForwarded) {
arg.onChange?.(event as Event); // okay
} else if (arg.type === 'number') {
arg.onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.valueAsNumber); // okay
} else {
arg.onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.value); // okay
}
},
[arg.onChange, arg.type]
);
But you've destructured that into variables. Again, if you did this completely, it would also work, because TypeScript supports narrowing destructured discriminated unions:
const Input = forwardRef<HTMLInputElement, TextInputProps | NumberInputProps>(({
className, isForwarded, placeholder, label,
suffix, disabled, autoFocus, onChange,
type, value
}, ref) => {
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement> | Event) => {
if (isForwarded) {
onChange?.(event as Event); // okay
} else if (type === 'number') {
onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.valueAsNumber); // okay
} else {
onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.value); // okay
}
},
[onChange, type]
);
Do note that you can still have the ...props
rest property; it just won't participate in narrowing. So you only need to destructure "completely" to the extent that all the participants in narrowing are their own variables:
const Input = forwardRef<HTMLInputElement, TextInputProps | NumberInputProps>(({
className, isForwarded, placeholder, label,
suffix, disabled, autoFocus, onChange,
type, ...props // <-- still usable
}, ref) => {
const handleOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement> | Event) => {
if (isForwarded) {
onChange?.(event as Event); // okay
} else if (type === 'number') {
onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.valueAsNumber); // okay
} else {
onChange?.((event as React.ChangeEvent<HTMLInputElement>).target?.value); // okay
}
},
[onChange, type]
);
Either of those approaches will works for you.
What doesn't currently work, is narrowing with partial destructuring, using a rest property. This is a missing feature of TypeScript requested in microsoft/TypeScript#46680. If that is ever implemented, then your code as-is will probably start working. But until and unless that happens, you'll need to work around it somehow, such as one of the two approaches above.