Optional Generic Function Argument

How to define a generic type argument for an optional function parameter in TypeScript

While performing a code review for Dinero.js, an awesome JavaScript money library maintained by Sarah Dayan, I noticed something odd. A newly added format function’s return type was unknown, when it was expected to be a generic.

An optional transformer parameter had recently been added to the function. If provided, the transformer should convert a money object to another type.

The Problem

function toDecimal<TOutput>(dineroObject: Dinero, transformer?: (value: string) => TOutput);

The intent was for TOutput to be inferred by the transform function return type. The default argument to transformer is a function that returns a string representation of a decimal.

There is no explicit return type. Type inference in the function definition was relied on to provide the return type.

function toDecimal(dineroObject, transformer = (value) => value as TOutput) {  const value: string = toDecimalFn(dineroObject);  return transformer(value);}

The return `value` has to be coerced to `TOutput`. Without using `as`, the compiler gave a very cryptic error.

Type 'string' is not assignable to type 'TOutput'.
  'TOutput' could be instantiated with an arbitrary type which could be unrelated to 'string'.ts(2322)

After some research, I figured out what was happening. TOutput is a generic argument and can be user provided. If provided, as in toDecimal<number>(d), there is a type conflict between the generic argument number and the return type of default transformer, which is string. The generic type can not be inferred by a default argument.

Requirements to make the typing work as intended:

  • If a transform argument isn’t provided:
    • The TOutput generic argument should not be used or required.
    • The return type of toDecimal should be string.
  • When a transform argument is provided:
    • TOutput should be inferred from the return type of the transformer argument.
    • The return type of toDecimal should be TOutput

I spend a lot of time thinking though how to use conditional types to solve this issue. Though fortunately Sarah put out a call on Twitter for help and Matt Pocock kindly provided an answer, use overloads.

The Fix: Using Overloads

Overloading the function declaration of toDecimal allows TOutput to be an optional generic argument. If not provided, the return type is string. Though if provided, the return type of toDecimal is inferred by the return type of the transformer, just as intended!

Since TOutput can’t be inferred by the return type of a default transformer argument, it was removed in favor of branching. In the overload declaration, TOutput doesn’t even exist if a transformer isn’t provided. Coercing it into TOutput was wrong, because it that situation it should explicitly be a string not a generic.

function toDecimal(dineroObject: Dinero): string;function toDecimal<TOutput>(dineroObject: Dinero, transformer: (value: string) => TOutput): TOutput;function toDecimal<TOutput>(dineroObject: Dinero, transformer?: (value: string) => TOutput) {  const value: string = toDecimalFn(dineroObject);  if (!transformer) return value;  return transformer(value);}

Link to the actual source of toDecimal with overloads

I hope this little case study helped you understand how to implement an optional generic argument in your own project.