场景描述

js 中通常会有一些辅助函数处理对数组中对象的转换工作, 有这么一个函数我们叫它transformProp, 它接收三个参数,

  1. 一个包含了对象的数组
  2. 一个属性名 prop
  3. 一个转换函数, 它对数组中每一个对象的prop属性做一个转换, 并返回转换后的对象数组

下面是此函数的一个简单的 js 实现:

1
2
3
4
5
6
7
8
function transformProp(objs, prop, mapper) {
  return objs.map((v) => {
    return {
      ...v,
      [prop]: mapper(v[prop]),
    };
  });
}

使用 typescript 开发一个安全的版本

首先我们以一个初步定义了第一个参数类型和 mapper 类型的 ts 函数开始

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function transformProp<T, RP>(
  objs: T[],
  prop: string, // TODO: 修复prop 类型
  mapper: (v: any) => RP // TODO: v的类型?
): any[] {
  // 返回值的类型?
  return objs.map((v) => {
    return {
      ...v,
      [prop]: mapper(v[prop]),
    };
  });
}

这个函数的实现和 js 版本一致, 只不过在函数声明中增加了一些类型标注, 第一个参数接受一个元素类型为T的数组, 第二个参数我们暂时定义它为string类型, 第三个参数我们指定为一个函数类型, 但是函数的参数暂时给了any类型, 它的返回值类型为RP. transformPop函数的返回值类型也暂时写为any.

在 ts 版本, 我们将以下面的类型作为测试数据,验证类型定义的正确性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
interface Person {
  id: number;
  name: string;
  age: number;
}

const students: Person[] = [
  { id: 1, name: "alice", age: 18 },
  { id: 2, name: "bob", age: 15 },
];

const student_suffix = transformProp(students, "name", (n) => n + " cool");
const student_name_length = transformProp(students, "name", (n) => n.length);
const student_name_length = transformProp(students, "name2", (n) => n.length); //TODO: 这里应该报错!

const student_name_length2 = transformProp(
  students,
  "name",
  (n: number) => n + 1
); // TODO: 这里也应该报错, 因为mapper函数的参数应当与 name字段的类型相同

const std1 = student_suffix[0];
const b = std1.name; // TODO: b的类型为string
const age = std1.age; // age的类型应当为number
const c = student_name_length[0].name; // TODO: c的类型为number

我们第一个版本的 ts 代码中, 由于使用stringany 放宽了类型约束,导致transformProp函数在接收到不存在的属性值时并不能检测到错误, 如何让编译器强制约束prop参数必须为T的属性呢?

使用keyof约束prop参数为T的属性

在 ts 中keyof T 表示一个值为类型T的一个属性, 我们可以为transformProp函数增加一个新的类型参数K 作为prop的类型,并要求K满足keyof T约束.

1
2
3
4
5
function transformProp<T, K extends keyof T, RP>(
  objs: T[],
  prop: K,
  mapper: (v: any) => RP // TODO: v的类型?
);

mapper函数要接收一个T类型中K字段的参数,所以它的类型就是 T[K], 更正后的函数为:

1
2
3
4
5
function transformProp<T, K extends keyof T, RP>(
  objs: T[],
  prop: K,
  mapper: (v: T[K]) => RP
);

完善增加了propmapper类型后, 下面的两个函数调用的类型问题都可以被编译器检查出来:

1
2
3
4
5
6
7
const student_name_length = transformProp(students, "name2", (n) => n.length); //TODO: 这里应该报错!

const student_name_length2 = transformProp(
  students,
  "name",
  (n: number) => n + 1
); // TODO: 这里也应该报错, 因为mapper函数的参数应当与 name字段的类型相同

类型使用Omit和类型交叉(intersection)完善返回值类型

对于transformProp这个函数, 它的返回值类型应当是什么呢? 可以总结如下两点

  1. 除了prop指定的属性外(即K), 其他属性与T类型一致
  2. 它的prop指定的属性(即K)的类型应当与mapper的返回值类型RP一致.

所以我们可以将返回值类型写为: Omit<T, K> & Record<K, RP>, 这里我们使用Omit辅助类型从T中去除K属性, 然后再使用交叉类型&为此类型与一个包含属性名K,属性值类型为RP的记录类型合并.

完善后的transformProp函数如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function transformProp<T, K extends keyof T, RP>(
  objs: T[],
  prop: K,
  mapper: (v: T[K]) => RP
): (Omit<T, K> & Record<K, RP>)[] {
  return objs.map((v) => {
    return {
      ...v,
      [prop]: mapper(v[prop]),
    };
  });
}

此函数可以对上面所列举的几种代码都能检测出相应的错误, 同时对于函数的返回值类型也能精确的推断出来.