最近给项目上Typescript,记录在迁移的过程中遇到的一个问题。
问题背景
下面这段代码 定义了一个User 接口, Company接口, Order接口以及相应的mongoose model。 User有一个外键关联的Company,和很多外键关联的Orders。
interface IUser & Document{
_id: string;
company: string | ICompany;
orders: string[] | IOrder[];
}
interface ICompany & Document{
_id: string;
name: string;
}
interface IOrder & Document{
_id: string
title: string;
}
const userSchema: Schema = new Schema({
company: { type: ObjectId, ref: 'Company' },
orders: [{ type: ObjectId, ref: 'Order' }]
});
const companySchema: Schema = new Schema({
name: String
});
const orderSchema: Schema = new Schema({
title: String
});
export const User: Model<IUser> = mongoose.model('User', userSchema);
const Company: Model<ICompany> = mongoose.model('Company', companySchema);
const Order: Model<IOrder> = mongoose.model('User', orderSchema);
复制代码
问题重现
使用时可能遇到如下场景:
import { User } from 'models'
User.findOne()
.populate({ 'path': 'company' })
.populate({ 'path': 'orders'})
.then(user => {
// 在此处尝试访问直接populate 出来的 company object的属性,会遇到编译器报错
// Property '_id' does not exist on type 'string | ICompany'.
// Property '_id' does not exist on type 'string'.
const companyId = user.company._id
// 在此处尝试访问order.map,会遇到编译器报错
// Cannot invoke an expression whose type lacks a call signature.
// '(<U>(callbackfn: (value: string, index: number, array: string[]) => U, thisArg?: any) => U[])
// | (<U>(callbackfn: (value: TOrder, index: number, array: TOrder[]) => U, thisArg?: any) => U[])'
// has no compatible call signatures.ts(2349)
user.orders.map(order => order)
})
复制代码
这两个问题都涉及到了对联合类型的理解的问题。在写这两行代码的时候我理所应当的认为联合类型就是 或(or)的意思。 即,取决于是否调用populate方法:
- company 可以是 string 或者 Company对象
- orders 可以使 string数组或者 Order对象的数组
那么理所应当user.company._id 和 user.orders.map 都应该可以直接调用而没有问题的。
问题原因
那么编译器为什么会报错呢? 仔细读了一下文档发现:
如果一个值是联合类型(Union Types),我们只能访问此联合类型的所有类型里共有的成员。
这里的联合类型并不是并集的意思,而可以理解为交集。
string
没有string._id
。编译器报错。
而第二个问题,则比较复杂而且一直有人在提解决方案,后面再说一下。
问题解决
那么这种情况改怎么解决呢?
类型断言/重载 type assertion
对于第一个问题,其实当时想到了一个方法就是用Type Assertion
const company = user.company
const companyId = (company as ICompany)._id
复制代码
这样编译器就不会报错了,虽然能解决问题,但其实并不是一个很安全的操作。需要在写代码的时候清晰的搞清楚什么时候populate了,什么时候没有populate。(虽然看起来很简单但这其实是增加了一个human error的机会)
所以又去读了读文档发现了:
自定义类型保护 (type guard) 利用typescript的自定义类型保护,这样编译器就能确认类型从而不会报错了。
function isCompany(obj: string | ICompany): obj is Company {
return obj && obj._id;
}
import { User } from 'models'
User.findOne()
.populate({ 'path': 'company' })
.populate({ 'path': 'orders'})
.then(user => {
if(isCompany(user.company)
const companyId = user.company._id
// ...
})
复制代码
数组类型定义使用混合类型
但是第二个问题就有点无语,两个类型都是数组,怎么连map都调用不了?
先给一个解决方案吧:
interface IUser & Document{
// 原来的声明 orders: string[] | IOrder[];
orders: (string | IOrder)[]
}
复制代码
这样编译器问题可以解决了,但是实际上这个声明会允许['id', order]
这样的混合数组存在。我们需要的是要么 ['id','id']
, 要么[order, order]
.
在github上这个issue也被提了很多次,最早可以追溯到2016年,并且最近依旧在不断被提起,感兴趣的可以去看一下:Call signatures of union types
另外,11天前有人开了一个新issue Wishlist: support for correlated record types 希望能解决这个问题。
Typescript语言的开发者Ryan也针对开发者jcalz的在最新的一个issue里评论了。
会持续关注这个问题。