1

I have the following component:

// ...

type StringOrNumber = string | number;

type InputProps<T extends StringOrNumber> = {
  value: T;
  onSubmit: (value: T) => void;
};

export default function Input<T extends StringOrNumber>(props: InputProps<T>) {
  const [value, setValue] = useState(props.value.toString());

  // Called on enter & blur
  const submitValue = () => {
    //  ts(2345): Argument of type 'string' is not assignable to parameter of type 'T'.
    props.onSubmit(value);
  };

  // ...

  return (
    <input
      {/* ... */}
      onChange={(e) => setValue(e.target.value)}
      value={value}
    />
  );
}

The reason for the error above inside submitValue is quite obvious as value is always of type string here, whilst onSubmit expects a string or a number, depending on the generic T of the component.

The problem is, that I don't know how to properly work around this error. I tried using type guards but that resulted in the same errors:

function isNumber(x: StringOrNumber): x is number {
  return typeof x === 'number';
}

// ...

export default function Input<T extends StringOrNumber>(props: InputProps<T>): JSX.Element {
  // ...
  const submitValue = () => {
    if (isNumber(props.value)) {
      const numberValue = parseFloat(value) || 0;
      //  ts(2345): Argument of type 'number' is not assignable to parameter of type 'T'.
      props.onSubmit(numberValue);
    } else {
      //  ts(2345): Argument of type 'string' is not assignable to parameter of type 'T'.
    props.onSubmit(value);
    }
  };
  // ...
}

I guess the issue is that the type guard is only checking the type of props.value here whilst it should somehow check what param type onSubmit actually expects.

How is this done correctly?

suamikim
  • 5,350
  • 9
  • 40
  • 75

1 Answers1

1

The problem is that extends StringOrNumber where StringOrNumber is string | number allows subtypes of string and number as well as those two types. String literal types are subtypes of string, and numeric literal types are subtypes of number. So even if you convert to string, you may not satisfy the constraint on T which could be more specific than that (the string literal type "example", for instance).

Your component can't correctly convert based on a generic type argument in the general case. So, three options for you:

  1. A fairly pragmatic, but technically incorrect, type assertion.

  2. Accept a conversion function in the props as well.

  3. Use a discriminated union instead of a generic, so there are always exactly two possibilities: string and number.

A fairly pragmatic, but technically incorrect, type assertion

#1 looks like this but is, again, technically incorrect:

// Called on enter & blur
const submitValue = () => {
    if (typeof props.value === "string") {
        props.onSubmit(String(value) as T);
    } else {
        props.onSubmit((parseFloat(value) || 0) as T);
    }
};

Playground link

Accept a conversion function

#2 looks like this:

type InputProps<T extends StringOrNumber> = {
    value: T;
    onSubmit: (value: T) => void;
    convert: (value: string) => T;
};

// ...and then when calling `onSubmit`...

const submitValue = () => {
    props.onSubmit(props.convert(value));
};

Playground link

(Or you could just make onSubmit accept string and do the conversion internally.)

Use a discriminated union

#3 looks like this:

type InputProps =
    {
        __type__: "string",
        value: string;
        onSubmit: (value: string) => void;
    }
    |
    {
        __type__: "number",
        value: number;
        onSubmit: (value: number) => void;
    };

// ...and the component function wouldn't be generic anymore:
export default function Input(props: InputProps) {
// ...

// ...and then when calling `onSubmit`...

const submitValue = () => {
    if (props.__type__ === "string") {
        props.onSubmit(String(value));
    } else {
        props.onSubmit((parseFloat(value) || 0));
    }
};

Playground link

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    FYI I went with the discriminated union as it seems like the "most technically correct" thing **and** gives me further flexibility. E.g. I could add properties which are only required by the number type etc. Thank you very much for listing all options in such detail! – suamikim Dec 15 '21 at 06:54
  • My pleasure! Yeah, despite being third on the list, it's my first choice too. :-) – T.J. Crowder Dec 15 '21 at 07:07