Home > Software design >  TypeScript: Generic function which returns the same type as parameter
TypeScript: Generic function which returns the same type as parameter

Time:05-19

Consider this (which doesn't compile):

function roundTo<T = number | null | undefined>(
  num: T,
  decimals: number,
): T {
  if (num === null || num === undefined) return num;

  const factor = Math.pow(10, decimals);
  return (Math.round(num * factor) / factor);
}

I would like to return the same type that's passed in to the roundTo() function.

For example:

const num1: number | null = 1.123456;
roundTo(num1, 1) // return type number | null

const num2: number = 1.123456;
roundTo(num2, 1) // return type number

const num3 = null;
roundTo(num3, 1) // return type null

The return type of roundTo is known at compile time, so the desire is to be able to carry the type forward from there based on the type passed in the first parameter.

I can make this compile by casting the return type as any, but that would break type safety. I can also make this compile by using extends instead of = and casing the return type as T, but it has the undesired behavior of returning any when null or undefined is passed in.

How can I get TypeScript to exhibit the desired behavior?

Related: https://stackoverflow.com/a/51195834/188740 https://stackoverflow.com/a/57529925/188740

CodePudding user response:

It should be T extends ..., not T = .... The latter form is "default value for T", and it is not inferred but rather always taken as declared, basically killing entire idea.

  function roundTo<T extends number | null | undefined>(num: T, decimals: number): T {
    if (num == null) return num;
    const factor = Math.pow(10, decimals);
    return (Math.round((num as number) * factor) / factor) as T;
  }

  function test(x: number, y: null, z: undefined, t?: number, u?: null) {
    let x1 = roundTo(x, 2); // -> number
    let x2 = roundTo(y, 2); // -> null
    let x3 = roundTo(z, 2); // -> undefined
    let x4 = roundTo(t, 2); // -> number | undefined
    let x5 = roundTo(u, 2); // -> null | undefined
  }

Actually, you can get rid of one type coersion num as number but probably not the other (at least not an easy way)

  function roundTo<T extends number | null | undefined>(num: T, decimals: number): T {
    if (typeof num === 'number') {
      // here typescript knows, that num is specific subtype of T - number
      // but doesn't extend this to the T itself
      const factor = Math.pow(10, decimals);
      return Math.round(num * factor) / factor as T;
    }
    return num;
  }

If you really want to force typescript infer actual type of T inside the function, probably conditional typings are the way, but imho it's overkill.

One more point from @Johnny Oshika - to avoid unwanted narrowing of number types overloads can be used:

  function roundTo(num: number, decimals: number): number;
  function roundTo<T extends number | null | undefined>(num: T, decimals: number): T;
  function roundTo<T extends number | null | undefined>(num: T, decimals: number): T {
   // function body
  }

CodePudding user response:

This seems to return the desired type for all use cases:

export function roundTo<T extends number | null | undefined>(
  num: T,
  decimals: number,
) {
  if (typeof num !== 'number') return num;

  const factor = Math.pow(10, decimals);
  return (Math.round(num * factor) / factor) as T extends number
    ? number
    : T;
}

Examples:

roundTo(undefined, 2) // Returns type `undefined`
roundTo(null, 2) // Returns type `null`
roundTo(1.234 as number | undefined, 2) // Returns type `number | undefined`
roundTo(1.234 as number | null, 2) // Returns type `number | null`
roundTo(1.234, 2) // Returns type `number`

Thanks to @jcalz's tip about conditional types. Without the conditional type assertion (i.e. as T extends number ? number : T), the inferred return type (number | T) mostly exhibits the desired behavior. The only exceptions are:

roundTo(undefined, 2) // Returns type `number | undefined`
roundTo(null, 2) // Returns type `number | null`
  • Related