最近給專案上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裡評論了。
會持續關注這個問題。