简体   繁体   中英

TypeScript definition for recursive function which returns the same structured object

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?

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.

Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM