Typescript 下 Mongoose 外來鍵型別&外來鍵陣列型別定義&型別保護&聯合型別理解

ycraaron發表於2019-04-06

最近給專案上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裡評論了。

會持續關注這個問題。

相關文章