Search code examples
reactjstypescript

Argument type is inferred as "never"


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:

  1. Argument of type 'Event' is not assignable to parameter of type 'never'. [2345]

Do you have any clue?


Solution

  • 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.

    Playground link to code