本文的翻译于<<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用这种方法进行测试。但是这样也存在一些问题:
- 必须创建命名的变量(如上面的lengths),却不会使用这些变量。
- 这意味着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去避免这些陷阱。