第四小节:好好利用结构类型
JS是天生的 duck 类型:如果你传了一个正确属性的值给一个函数,那么JS不会管你怎么使用的。TS为这种行为建模,有时候会得到意外的结果,因为类型检测器理解的类型可能会比你所想的更宽泛些。掌握好结构类型将帮助您识别错误,并帮助您编写更健壮的代码。
假设你正在研究一个物理库,有一个2D向量类型:
interface Vector2D {
x: number;
y: number;
}
你写了一个方法来计算它的长度:
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
现在你介绍命名向量的概念:
interface NamedVector {
name: string;
x: number;
y: number;
}
calculateLength 函数将会以 NamedVector类型参数来执行,因为 x 跟 y 是其中的两个参数成员。TS足够智能,可以识别出来:
const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v); // OK, result is 5
有趣的是,你并没有声明 NamedVector 跟 Vector2D 之间的关系。而且你也没有为 NamedVector 写一个替代的方法来重新实现 calculateLength 。TS的类型系统是建模于JS的运行时行为。它允许使用 NamedVector来调用 calculateLength 方法,因为它的结构跟 Vector2D 是兼容的。这也是结构类型的由来。
但这也会带来问题,假设你加一个3D向量类型:
interface Vector3D {
x: number;
y: number;
z: number;
}
并且写一个函数来规范它们(使它们长度为1):
function normalize(v: Vector3D) {
const length = calculateLength(v);
return {
x: v.x / length,
y: v.y / length,
z: v.z / length,
};
}
如果你调用这个函数,很可能得到的长度比单位长度更长。
> normalize({x: 3, y: 4, z: 5})
{ x: 0.6, y: 0.8, z: 1 }
是哪里不对?为什么TS没有捕获到错误?
问题出在,本来 calculateLength 在 2D向量上工作的,被用在了3D向量上工作了。所以,z 在规范化的时候被忽略掉了。
也许更意外的是,TS类型检查器没有发现这个问题。尽管类型声明的时候说是2D向量,为什么你还允许用3D矢量来调用 calculateLength ?
挺好的命名向量在这却适得其反。用 {x, y, z}调用 calculateLength 也没有抛出错误。所以类型检查器也没有阻止,这样的行为导致了一个bug。(如果你想类型检测器报错,则需要一些配置。在第37小节会有样例)
当你写函数的时候,你能想象到它会被你声明的一些属性调用,也可以被一些其它的属性调用。这被称为封闭类型跟精确类型,而且没能在TS类型系统中说明。不管你喜欢与否,你的类型是开方式的。
下面的代码有时会有意外:
function calculateLengthL1(v: Vector3D) {
let length = 0;
for (const axis of Object.keys(v)) {
const coord = v[axis];
// ~~~~~~~ Element implicitly has an 'any' type because ...
// 'string' can't be used to index type 'Vector3D'
length += Math.abs(coord);
}
return length;
}
为什么会报一个错误?因为 axis 是类型为 Vector3D 的 V,所以应该是 "x", "y", 或者 "z" 其中一个。而且,由于声明的是 Vector3D, 所有的成员应该是 number, 因此 coord 的类型不应该是一个nunber吗?
这个错误是个烟雾弹吗?不是!TS的提示是对的。上一段中的逻辑假设 Vector3D 是密封的,没有其他属性。但是可能是:
const vec3D = {x: 3, y: 4, z: 1, address: '123 Broadway'};
calculateLengthL1(vec3D); // OK, returns NaN
因为 v 可以有任务属性,axis 的类型是 string。TS没有理由相信 v[axis] 是一个 number ,因为正如你所见,它也许不是。对象上的迭代很难正确的确定类型。在第54小节,我们再回过头看这个问题。但在这种情况下,下面没有用循环的实现会更好一些:
function calculateLengthL1(v: Vector3D) {
return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}
结构类型也会在类使用上产生意外,因为它们在结构上比较了可分配性:
class C {
foo: string;
constructor(foo: string) {
this.foo = foo;
}
}
const c = new C('instance of C');
const d: C = { foo: 'object literal' }; // OK!
为什么d可以匹配C?因为它有一个foo属性,类型是string。另外,它有一个构造器可以用一个参数调用(尽管它通常无参数调用)。 所以结构是匹配的。如果你在C的构造函数中写了逻辑,并编写一个让它运行的函数,这可能会导致意外情况发生。这就很不同于其它的语言,比如C++或者Java ,它们如果声明类型C的变量,必须保证它是C或者C的子类。
当你写测试代码的时候结构类型会很有用。假如有一个用来查询数据库获取查询结果的方法:
interface Author {
first: string;
last: string;
}
function getAuthors(database: PostgresDB): Author[] {
const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`);
return authorRows.map(row => ({first: row[0], last: row[1]}));
}
为了测试,你要创建一个 mock PostgresDB。但是有更好的实现办法,用结构类型定义一个较窄的接口:
interface DB {
runQuery: (sql: string) => any[];
}
function getAuthors(database: DB): Author[] {
const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`);
return authorRows.map(row => ({first: row[0], last: row[1]}));
}
你仍然可以在生产中向getAuthors传递PostgresDB,因为它有一个runQuery方法。因为有结构类型,不必用 PostgresDB 来实现 DB 。TS 知道能这么干。
当写测试代码的时候,你能用简单的对象替代:
test('getAuthors', () => {
const authors = getAuthors({
runQuery(sql: string) {
return [['Toni', 'Morrison'], ['Maya', 'Angelou']];
}
});
expect(authors).toEqual([
{first: 'Toni', last: 'Morrison'},
{first: 'Maya', last: 'Angelou'}
]);
});
TS 会验证我们的测试DB是否符合接口。而且测试的时候不需要知道生产数据库的任何信息:不需要mock库!通过引入抽象(DB),我们将逻辑(和测试)从特定实现(PostgresDB)的细节中解放出来。另一个结构类型的优势,就是它能清晰的切断库之间的依赖关系。更多的内容见第51小节。
应该记住的点
- 搞明白 JS 是 duck 类型,TS 用结构类型来进行模拟:可分配给接口的值的属性可能超出了类型声明中显式列出的属性。所以它们的类型不是封闭的。
- 注意,类也是遵循结构类型规则的。你拿到的类实例有可能不是你所期望的!
- 用结构类型助力单元测试。