[译]<<Effective TypeScript>> 技巧52:意识到测试types的陷阱

302 阅读5分钟

本文的翻译于<<Effective TypeScript>>, 特别感谢!! ps: 本文会用简洁, 易懂的语言描述原书的所有要点. 如果能看懂这文章,将节省许多阅读时间. 如果看不懂,务必给我留言, 我回去修改.

技巧52:意识到测试types的陷阱

人们发布自己代码的时候,会写测试用例。同样的对types写测试用例同样重要。那你知道如何对自己types写测试用例吗? 使用ts提供的工具写测试用例非常有吸引力,但是这个方法有几个陷阱。使用dtlint这类工具写测试用例,因为这类工具独立与ts的类型系统,会更安全。

当你为map函数(来自于实用的工具函数库)写好了type:

declare function map<U, V>(array: U[], fn: (u: U) => V): V[];

如何确认这样的类型声明产生了期待的类型? 一个常见的方法写个测试文件调用这个函数:

map(['2017', '2018', '2019'], v => Number(v));

这个方法只能进行一些简单直接的检查,例如你只传入给map一个参数。但是这个检查太简单了,就好好比下面的检查:

test('square a number', () => {
  square(1);
  square(2);
});

这样的检查的确不报错,非常简单。但是没有对返回值进行检查。我们可以这样对返回值类型进行检查:

const lengths: number[] = map(['john', 'paul'], name => name.length);

但是这样写返回值类型非常多余(见技巧19)。但是在写类型测试用例却有必要:确保了map对类型做了一些合理的操作。 事实上很多@types用这种方法进行测试。但是这样也存在一些问题:

  1. 必须创建命名的变量(如上面的lengths),却不会使用这些变量。
  2. 这意味着ide会对你进行一些lint警告,你只能忽略这些警告

常见的解决办法是定义一个助手:

function assertType<T>(x: T) {}

assertType<number[]>(map(['john', 'paul'], name => name.length));

这样限制了未使用变量的问题,但是还存在一个问题:这样检查的是两个type的赋值性,而不是相等性:

const n = 12;
assertType<number>(n);  // OK

这里的n的type就是12。也就是number的子集,所以可赋值性检查通过。当你检查object类型时候,就有问题了:

const beatles = ['john', 'paul', 'george', 'ringo'];
assertType<{name: string}[]>(
  map(beatles, name => ({
    name,
    inYellowSubmarine: name === 'ringo'
  })));  // OK

调用call函数返回:{name: string, inYellowSubmarine: boolean}[],可以赋值给{name: string}[]。根据上下文,你真的可能想检查类型是否相等。

如果你的函数返回另外一个函数,你就需要慎重考虑什么是可赋值性了:

const add = (a: number, b: number) => a + b;
assertType<(a: number, b: number) => number>(add);  // OK

const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double);  // OK!?

你可能会惊讶第二个断言也能通过?!原因在于:js允许你传给一个函数的参数比其声明的参数更少。

const g: (x: string) => any = () => 12;  // OK

ts选择对这个行为进行建模而不是禁止。因为这个行为在回调函数中随处可见。例如map函数可以接受三个参数:

map(array, (name, index, array) => { /* ... */ });

虽然三个参数都可用,但是第一个参数,或者前两个参数的情况更为常见。同时使用三个参数非常少见。

那么我们该怎么做?我们可以拆分函数类型,用泛型Paramters 和ReturnType来测试函数的每个部分:

const double = (x: number) => 2 * x;
let p: Parameters<typeof double> = null!;
assertType<[number, number]>(p);
//                           ~ Argument of type '[number]' is not
//                             assignable to parameter of type [number, number]
let r: ReturnType<typeof double> = null!;
assertType<number>(r);  // OK

这有另外一个问题:map会对回调函数的this的值进行设置。ts可以对这种行为进行建模。那么你的type声明和type测试也需要做到这一点。

我对于map的测试到目前为止仅仅只是一个黑箱子测试。我们只是测试了入参和返回值的类型,但是没有测试了中间步骤的细节。我们可以在回调函数中进行测试:

const beatles = ['john', 'paul', 'george', 'ringo'];
assertType<number[]>(map(
  beatles,
  function(name, i, array) {
// ~~~~~~~ Argument of type '(name: any, i: any, array: any) => any' is
//         not assignable to parameter of type '(u: string) => any'
    assertType<string>(name);
    assertType<number>(i);
    assertType<string[]>(array);
    assertType<string[]>(this);
                      // ~~~~ 'this' implicitly has type 'any'
    return name.length;
  }
));

这暴露了this类型的问题。注意这里非箭头函数的使用 这有一个能通过测试用例的请求。

declare function map<U, V>(
  array: U[],
  fn: (this: U[], u: U, i: number, array: U[]) => V
): V[];

还剩下最后一个问题:也是最重要的问题。下面是对整个模块进行类型声明,这能通过最严格的测试,但是危害非常严重!

declare module 'overbar';

它将any指定给整个模块,你可以通过所有的测试,但是你将失去类型安全。更糟糕的是,你调用这个模块的函数有可能会产生any。这样会将any到处感染危害你的类型安全。

有一个类型声明工具dtslint,他利用有规则的注释来对你的类型进行检查:

const beatles = ['john', 'paul', 'george', 'ringo'];
map(beatles, function(
  name,  // $ExpectType string
  i,     // $ExpectType number
  array  // $ExpectType string[]
) {
  this   // $ExpectType string[]
  return name.length;
});  // $ExpectType number[]

dtslint对相等性而不是赋值性进行检查,这更符合人们的直觉。但是也有一个问题:number | string 和 string | number类型相同,但是dtslint认为不同。 同样的情况出现在string 和any。

对type进行测试是非常有技术含量的操作。你应该意识到常见技术的陷阱,同时使用dtslint去避免这些陷阱。