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`