Let's say we have a function which should process any nested object and return a new object with the same keys recursively. How to cast it properly in TypeScript?
For example, consider the following function:
function addCssSuffix(input, suffix = 'px') {
if (input && typeof input === 'object') {
return Object.entries(input).reduce((out, [key, value]) => {
out[key] = addCssSuffix(value, suffix)
return out
}, Array.isArray(input) ? [] : {})
} else if ((typeof input === 'string' && !isNaN(Number(input))) || typeof input === 'number') {
return input.toString() suffix
} else {
return input
}
}
This function might be used for wrapping style attributes. It tries to append any value with px
suffix recursively. If just a 50
number is passed, then it returns 50px
. For nested structures it appends the numeric-like values with px
suffix:
addCssSuffix(50) -> "50px"
addCssSuffix({ width: 10, position: 'absolute' }) -> { width: "10px", position: "absolute" }
addCssSuffix([{ width: 10 }, { height: 20 }, 10]) -> [{ width: "10px" }, { height: "20px" }, "10px"]
Here's my attempt to rewrite in in TypeScript. We have to use interface because it can refer to itself. This one seems to work:
interface RecursiveInterface<T> {
[key: string]: T | RecursiveInterface<T>
}
type RecursiveType<T> = T | RecursiveInterface<T>
function addCssSuffix<T>(input: RecursiveType<T>, suffix = 'px') {
if (input && typeof input === 'object') {
return Object.entries(input).reduce<any>((out, [key, value]) => {
out[key] = addCssSuffix(value, suffix)
return out
}, Array.isArray(input) ? [] : {})
} else if ((typeof input === 'string' && !isNaN(Number(input))) || typeof input === 'number') {
return input.toString() suffix
} else {
return input
}
}
However, it does not inherit the incomming argument structure properly. It's natural to expect that const i = addCssSuffix(50)
should define i
as string
while const j = addCssSuffix({ width: 50 })
should define j
as { width: string }
. How to achieve this?
CodePudding user response:
The approach I'd take is to make addCssSuffix()
generic in both the type T
of input
and the type S
of suffix
, and then write an AddCssSuffix<T, S>
recursive conditional type to compute the type of the output. So the call signature looks like:
function addCssSuffix<const T, S extends string = "px">(
input: T, suffix?: S): AddCssSuffix<T, S>;
Note that I make S
default to the literal type "px"
so that if you don't pass a suffix
then the compiler will know what S
is.
Also I'm using the const
modifier for type parameters, as implemented in microsoft/TypeScript#51865, which is slated to be released with TypeScript 5.0. This gives the compiler a hint that we'd like it to infer very specific types for input
, as if the caller had used a const
assertion. Until this is released you can just remove the const
before T
and manually use const
assertions or other weird magic as described in microsoft/TypeScript#30680.
Okay, so here's AddCssSuffix<T, S>
:
type AddCssSuffix<T, S extends string> =
T extends object ? { [K in keyof T]: AddCssSuffix<T[K], S> } :
T extends number | `${number}` ? `${T}${S}` : T;
Basically, if T
is an object type, then we map over it and apply AddCssSuffix
to each of its properties. This automatically maps arrays and tuples into arrays and tuples as well.
Otherwise, if T
is a number
type or a number
-like string (represented by `${number}`
, a "pattern template literal type" as implemented in microsoft/TypeScript#40598), then we append the suffix to it via the template literal type `${T}${S}`
.
Otherwise, then we return T
.
This mirrors your implementation, more or less.
The compiler isn't smart enough to understand that the implementation and the call signature agree, so we'll need something to suppress type errors in the implementation. We could use a bunch of type assertions, but it's easier to just use a single call-signature overload, where the implementation uses the any
type to loosen type checks:
function addCssSuffix<const T, S extends string = "px">(
input: T, suffix?: S): AddCssSuffix<T, S>;
function addCssSuffix(input: any, suffix = 'px') {
if (input && typeof input === 'object') {
return Object.entries(input).reduce<any>((out, [key, value]) => {
out[key] = addCssSuffix(value, suffix)
return out
}, Array.isArray(input) ? [] : {})
} else if ((typeof input === 'string' && !isNaN(Number(input))) || typeof input === 'number') {
return input.toString() suffix
} else {
return input
}
}
Okay, let's test it out:
const i = addCssSuffix(50);
// const i: "50px"
const j = addCssSuffix({ width: 50 });
// const j: { readonly width: "50px"; }
const k = addCssSuffix(
{
a: null, b: "hello", c: "123", d: 456, e: true,
f: false, g: { h: { i: 1 } }, j: undefined,
k: 32n, l: [1, "2", "three"]
}, "em");
/* const k: {
readonly a: null;
readonly b: "hello";
readonly c: "123em";
readonly d: "456em";
readonly e: true;
readonly f: false;
readonly g: {
readonly h: {
readonly i: "1em";
};
};
readonly j: undefined;
readonly k: 32n;
readonly l: readonly ["1em", "2em", "three"];
} */
console.log(JSON.stringify(k, (k, x) => typeof x === "bigint" ? Number(x) : x, 2))
/* {
"a": null,
"b": "hello",
"c": "123em",
"d": "456em",
"e": true,
"f": false,
"g": {
"h": {
"i": "1em"
}
},
"k": 32,
"l": [
"1em",
"2em",
"three"
]
} */
Looks good. The compiler has produced very specific types for the output of addCssSuffix()
, including object types, array types, numbers, numeric strings, and non-numeric strings, and all the non-transformed inputs.