首页 > 解决方案 > TypeScript:如何在类型之间来回转换对象

问题描述

假设我有两种类型

type A = {
  fi_name: string;
  l_name: string;
  f_name: string;
}

type B = {
  firstName: string;
  lastName: string;
  fullName: string;
} 

有没有一种方法可以轻松地在这些类型之间来回转换对象?

如果答案还处理嵌套类型和/或可以使用 expressjs 中间件实现,则额外的互联网点。

标签: javascriptnode.jstypescriptexpresscasting

解决方案


我不确定任何互联网点是否值得为解决任意嵌套属性的问题而付出努力。为了以编程方式执行此操作,您需要您的函数接受从一种类型到另一种类型的“映射”模式。例如,给定您的类型Aand type B,您可以这样做:

const aToB = makeMapper<A, B>()({
  f_name: "fullName",
  fi_name: "firstName",
  l_name: "lastName"
} as const);

在这里,您可以看到假设makeMapper函数是通用的,允许您指定所需的输入类型 ( A) 和所需的输出类型 ( B),然后其输出接受映射模式,其中每个属性名称都映射到另一个属性名称。(这是一个柯里化函数,因为 TypeScript 缺少类型参数的部分规范;您需要输入和输出的类型参数以及映射的类型参数。有关更多信息,请参见此答案。)(此外,您需要该const断言以确保编译器知道字符串文字值,例如"fullName"并且不会将它们扩大到string)。

如果您在映射中做错了什么,您会看到某种错误消息:

const badMapper = makeMapper<A, B>()({
  f_name: "fullName",
  fi_name: "firstName",
  l_name: "lostName",
} as const); // error!
// 'Invalid<{ lastName: "missing or misspelled property key"; }>'

在这里我写了lostName而不是lastName所以我想要上面类型的错误告诉我我lastName在某种程度上弄错了属性。

对于对象属性,您需要映射模式不仅接受重命名的键,还接受对象类型的映射器。例如:

interface AccountA {
  user: A
}
interface AccountB {
  user: B
}
const accountAToAccountB = makeMapper<AccountA, AccountB>()({
  user: ["user", aToB]
} as const);

这里AccountAtoAccountB映射器保留了属性的名称,但使用我们之前创建的映射器user将类型从Ato更改。BaToB

然后,实际向前和向后使用生成的映射器将如下所示:

const a: AccountA = {
  user: {
    fi_name: "Harry",
    l_name: "Potter",
    f_name: "Harry Potter"
  }
}

const b: AccountB = accountAToAccountB.map(a);

console.log(b);
/* {
  "user": {
    "firstName": "Harry",
    "lastName": "Potter",
    "fullName": "Harry Potter"
  }
}  */

const aAgain: AccountA = accountAToAccountB.reverseMap(b);
console.log(aAgain);
/* {
  "user": {
    "fi_name": "Harry",
    "l_name": "Potter",
    "f_name": "Harry Potter"
  }
} */

正确的?


好吧,你可以在 TypeScript 中做到这一点,但是类型非常丑陋并且涉及到,并且实现需要大量类型断言。以下是如何做到这一点:

class Mapper<T extends object, M extends ObjectMapping<T>> {
  constructor(public mapping: M) { }
  map(obj: T): Mapped<T, M> {
    return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
      if (!(k in this.mapping)) return [k, v];
      const m = this.mapping[k as keyof T];
      if (Array.isArray(m)) return [m[0], m[1] ? m[1].map(v) : v];
      return [m, v];
    })) as any;
  }
  reverseMap(obj: Mapped<T, M>): T {
    const revMapping: Record<string, any> = Object.fromEntries(Object.entries(this.mapping).map(([k, m]) => {
      if (Array.isArray(m)) return [m[0], [k, m[1]]];
      return [m, k];
    }));
    return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
      if (!(k in revMapping)) return [k, v];
      const m = revMapping[k];
      if (Array.isArray(m)) return [m[0], m[1] ? m[1].reverseMap(v) : v];
      return [m, v];
    })) as any;
  }
}

type ObjectMapping<T extends object> = {
  [K in keyof T]: T[K] extends object ? readonly [PropertyKey, Mapper<T[K], any>?] : PropertyKey
}

type Mapped<T extends object, M extends ObjectMapping<T>> = {
  [K in keyof T as GetKey<M[K]>]: GetVal<M[K], T[K]>
} extends infer O ? { [K in keyof O]: O[K] } : never;
type GetKey<P> = P extends PropertyKey ? P : P extends readonly [PropertyKey, any?] ? P[0] : never;
type GetVal<P, V> = P extends PropertyKey ? V :
  P extends readonly [PropertyKey, Mapper<any, any>] ? V extends object ? Mapped<V, P[1]['mapping']> : V : V;

type Invalid<Err> = {
  errMsg: Err
}

function makeMapper<T extends object, U extends object>() {
  return <M extends ObjectMapping<T>>(mapping: (Mapped<T, M> extends U ? M : M & Invalid<
    { [K in keyof U as K extends keyof Mapped<T, M> ? Mapped<T, M>[K] extends U[K] ? never : K : K]:
      K extends keyof Mapped<T, M> ? Mapped<T, M>[K] extends U[K] ? never :
      ["wrong property type", U[K], "vs", Mapped<T, M>[K]] : "missing or misspelled property key" }
  >)) => new Mapper<T, M>(mapping as M)
}

我应该费心解释每一部分是如何工作的吗?打字基本上是在映射类型中递归使用 TS4.1 的键重新映射。该实现还递归地遍历mapping模式并将映射应用于输入对象的条目。

面对如此复杂的类型,让编译器生成有用的错误消息并不简单,因为目前 TypeScript 中没有“抛出/无效”类型(参见microsoft/TypeScript#23689),所以我不得不使用一种解决方法。

那里可能有各种各样的边缘情况,当涉及到属性是基元和其他对象类型的联合的对象类型时,或者当属性名称发生冲突时等等。这样的事情需要在类型系统和在在您考虑在任何类型的生产环境中使用它之前运行时。

因此,您可能会考虑实际用例的范围以及您是否真的想要一些通用的重新映射器,或者是否更适合硬编码的方法。A对于像and这样的一对类型B,即使没有边缘情况,上述实现也可能是矫枉过正。对于您的实际用例?只有你能说。

Playground 代码链接


推荐阅读