天天看點

TypeScript 疑難雜症

作者:阿偉 - 身在高樓心在北大荒,我就這副死樣~https://zhuanlan.zhihu.com/p/82459341

互斥類型

2019.09.19 新增
// https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-373782604
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
type XOR<T, U> = (T | U) extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U           

複制

使用上面的

XOR

範型,我們可以很容易地實作如下需求:

  • 如果類型是 A,那絕不可能是 B
// https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types

interface Person {
  ethnicity: string
}
interface Pet {
  breed: string
}
function getOrigin(value: XOR<Person, Pet>) {}

getOrigin({}) //Error
getOrigin({ ethnicity: 'abc' }) //OK
getOrigin({ breed: 'def' }) //OK
getOrigin({ ethnicity: 'abc', breed: 'def' }) //Error           

複制

不要想當然的認為可以這樣:Person | Pet
  • 某個對象中要不有屬性

    a

    ,要不有屬性

    b

    ,但二者不能同時都有

一個常見的例子是頁面導航菜單元件的配置,如果包含了

path

就不可能包含

children

,偷懶的做法是:

type Option = { name: string, path?: string, children?: Option[] }           

複制

上面這個顯然不夠類型安全,而且在你解析該配置的時候也不夠友善,比如,你不能這樣:

if (option.children) doSthWithChildren(option.children)
else if (option.path) doSthWithPath(option.path) // 當不存在 children 的時候,這裡也并不能自動推導出有 path,你必須得顯式判斷           

複制

利用上面的

XOR

,可以這樣:

type Option = {name: string} & XOR<{path: string}, {children: Option[]}>
// type Option = {name: string} & XOR<{path: string, icon: string}, {children: Option[]}> // 在增加個 icon 屬性也是可以的           

複制

不要想當然的認為可以這樣:{name: string, path: string} | {name: string, children: Option[]}
  • 某個對象中某些屬性要不都有,要不就一個都别有

b

c

總是會成對出現,要不就不出現:

type Object = { a: string } & XOR<{}, { b: string, c: number }>           

複制

  • 大于 2 個的互斥類型該怎麼做?
type Test = XOR<A, XOR<B, C>>           

複制

很難過,據我所知 TypeScript 還不支援"不定泛型",是以你沒法讓

XOR

可以支援這樣:

type Test = XOR<A, B, C, ...>           

複制

  • XOR 的一個 bug ?

原因不明。。。

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
type XOR<T, U> = (T | U) extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U

type Option = { name: string } & XOR<{ path: string }, { children: Option[] }>

// const option: Option = {name: '1', path: ''} // ok
// const option: Option = {name: '1', children: []} // ok
// const option: Option = {name: '1', value: '', children: []} // error, but ok

declare const option: Option

if (option.children) option.children.concat()
else option.path.toLocaleUpperCase()

if (option.path) option.path.toLocaleUpperCase()
else option.children.concat() // Object is possibly 'undefined'!! why?           

複制

我知道原因的時候會回來更新,如果你知道的話也歡迎留言~

讓 BindCallApply“更安全”

2019.09.19 新增

不知道你有沒有注意過這個問題:

function test(a: number) {}
test.call(null, '1') // 你傳入了一個 string,但并不報錯           

複制

類似的還有

bindapply

都有這個問題。不過在

3.2

版本後,你可以通過開啟 strictBindCallApply來使你的 BindCallApply“更安全”。感興趣的話還可以來看一下它的 實作。

限制傳入對象必須包含某些字段

用于給某個處理特定對象的函數來限制傳入參數,尤其是當對象的某些字段是可選項的時候,比如說:

test

函數接受的參數類型為:

interface Param {
  key1?: string
  key2?: string
}           

複制

這種情況下調用方傳入

{}

也可通過類型檢測。如果對字段類型沒有嚴格要求,隻希望限制必須包含某些字段可以這麼做:

type MustKeys = 'key1' | 'key2'
function test<T extends MustKeys extends keyof T ? any : never>(obj: T) {}
test({})
test({ key1: 'a' })
test({ key1: 'a', key2: 1 })           

複制

善用 infer 來做類型“解構”

比如你想擷取某個被

Promise

包裝過的原類型

type PromiseInnerType<T extends Promise<any>> = T extends Promise<infer P>
  ? P
  : never
type Test = PromiseInnerType<Promise<string>> // Test 的類型為 string           

複制

其實上面就是 infer的通常用途。舉一反四,任意工具類型都可以用上述原理來做類型“解構”。比如,你還可以寫一個

ReturnType

的 Promise 版本,用于擷取 Promise 函數的"解構類型"

type PromiseReturnType<T extends () => any> = ReturnType<T> extends Promise<
  infer R
>
  ? R
  : ReturnType<T>

async function test() {
  return { a: 1 }
}

type Test = PromiseReturnType<typeof test> // Test 的類型為 { a: number }           

複制

如果你用 React 的話,不難想到可以利用上述原理從一個元件中擷取 PropsType。不過,現在你可以直接使用 React.ComponentProps來拿到

将聯合類型轉成交叉類型

// https://stackoverflow.com/a/50375286/2887218
type UnionToIntersection<U> = (U extends any
  ? (k: U) => void
  : never) extends ((k: infer I) => void)
  ? I
  : never

// { a: string } | { b: number } => { a: string } & { b: number }
type Test = UnionToIntersection<{ a: string } | { b: number }>           

複制

将交叉類型“拍平”

這個一般用于希望調用方能得到更清爽的類型提示,比如你的某個函數的參數類型限制為:

type Param = { a: string } & { b: number } & { c: boolean }           

複制

在調用的時候,實時提示會巨長,不友善檢視,是以你可以用如下工具類型:

type Prettify<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type Param = Prettify<{ a: string } & { b: number } & { c: boolean }>           

複制

Param

将等同于如下類型:

type Param = { a: string; b: number; c: boolean }           

複制

從一個函數數組中擷取所有函數傳回值的合并類型

函數數組為:

function injectUser() {
  return { user: 1 }
}

function injectBook() {
  return { book: '2' }
}

const injects = [injectUser, injectBook]           

複制

如何實作一個工具類型來擷取上面這個

injects

數組中每個函數傳回值的合并類型呢?即:

{ user: number, book: string }

type UnionToIntersection<U> = (U extends any
  ? (k: U) => void
  : never) extends ((k: infer I) => void)
  ? I
  : never
type Prettify<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type InjectTypes<T extends Array<() => object>> = T extends Array<() => infer P>
  ? Prettify<UnionToIntersection<P>>
  : never

type Test = InjectTypes<typeof injects> // Test 的類型為 { user: number, book: string }           

複制

利用上面的原理,你可以很容易地實作這個需求:

實作一個 getInjectData 函數,它接受若幹個函數參數,傳回值為這些函數傳回對象的合并結果
function getInjectData<T extends Array<() => any>>(injects: T): InjectTypes<T> {
  const data: any = {}
  for (let inject of injects) Object.assign(data, inject())
  return data
}
getInjectData([injectUser, injectBook]) // { user: number, book: string }           

複制

...args 函數不定參數 + 泛型

接着上面的

getInjectData

繼續看,有個小缺點是你必須得給它傳一個數組,而不能傳不定參數(如果你用下面的方式實作的話:

function getInjectData<T extends () => any>(...injects: T[]): InjectTypes<T[]> {
  const data: any = {}
  for (let inject of injects) Object.assign(data, inject())
  return data
}
getInjectData(injectUser, injectBook) // Argument of type '() => { book: string; }' is not assignable to parameter of type '() => { user: number; }'           

複制

原因是 TypeScript 會取第一個參數作為

injects

數組元素的類型。解決方案是:

function getInjectData<T extends Array<() => any>>(
  ...injects: T
): InjectTypes<T> {
  const data: any = {}
  for (let inject of injects) Object.assign(data, inject())
  return data
}
getInjectData(injectUser, injectBook) // { user: number, book: string }           

複制

原理是 TypeScript 會為使用了不定參數運算符的每個參數自動解包數組泛型和其一一映射

自己實作一個“完美的” Object.assign 類型

2019.09.21 新增

在你了解了上面的

聯合類型轉成交叉類型

...args 函數不定參數 + 泛型

之後,我們可以嘗試來“完善”一下 Object.assign 的類型。

這裡之是以說完善它,原因是在你給該函數傳入超過 4 個對象之後,它會傳回

any

,而不再是所有對象的交叉類型:

原因可以看下官方的類型實作:Object.assign

type UnionToIntersection<U> = (U extends any
  ? (k: U) => void
  : never) extends ((k: infer I) => void)
  ? I
  : never

function assign<T extends Array<any>>(
  ...args: T
): UnionToIntersection<T extends Array<infer P> ? P : never> {
  return {} as any
}

assign({ a: 1 }, { b: '2' }, { c: 3 }, { d: '4' }, { e: 5 })           

複制

引用某個類型的子類型

type Parent1 = { fun<T>(): T }
type Parent2 = { [key: string]: number }
type Child1 = Parent1['fun']
type Child2 = Parent2['']           

複制

但是好像沒有辦法直接“固化”上面

fun

函數的泛型,就像這樣:

type Child1 = Parent1['fun']<number>           

複制

如果找到好辦法我再來更新吧。。

動态更改鍊式調用的傳回值類型

type Omit<T, K> = Pick<T, Exclude<keyof T, K>> // TypesScript 3.5 已自帶

class Chain<T = number> {
  fun1() {
    return this as Omit<this, 'fun1'>
  }

  fun2(arg: T) {
    return (this as unknown) as Chain<string>
  }

  fun3() {
    return this
  }
}           

複制

如上代碼,希望實作調用

fun1

後,不能再調用

fun1

,調用

fun2

後,之後

fun2

隻允許傳 string。

然後 測試一下會發現在調用完

fun2

後,

fun1

又可以調用了,并不符合預期。怎麼永久剔除掉

fun1

呢?如下代碼:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>> // TypesScript 3.5 已自帶

class Chain<T = number> {
  fun1<P extends Partial<Chain<T>>>(this: P) {
    return this as Omit<P, 'fun1'>
  }

  fun2<P extends Partial<Chain<T>>>(this: P, arg: T) {
    return (this as unknown) as Pick<
      Chain<string>,
      Extract<keyof P, keyof Chain<string>>
    >
  }

  fun3<P>(this: P) {
    return this
  }
}           

複制

不過壞處是所有對外的方法你都得顯示聲明一遍

this

的類型才可以維持這份 "動态類型"。尤其是在方法内部需要調用 this 中的其他資料時候,往往得各種 as any。如果你有更好的辦法,歡迎留言~

操作兩個字段相近的對象

const obj1 = { a: 1, b: 2, c: 3 }
const obj2 = { a: 1, b: 2, d: 3 }
const keys = ['a', 'b']
for (let key of keys) {
  console.log(obj1[key])
  console.log(obj2[key])
}           

複制

上述代碼在開啟了 noImplicitAny後是會報錯的,那除了

@ts-ignore

如何正确地聲明

keys

的類型呢?如下代碼:

const obj1 = { a: 1, b: 2, c: 3 }
const obj2 = { a: 1, b: 2, d: 3 }
const keys: Array<keyof typeof obj1 & keyof typeof obj2> = ['a', 'b']           

複制

這個問題相對簡單些,當時想着

@ts-ignore

能支援 block 就好了,可惜目前為止還沒支援,可以關注這個 issue

讓對象的類型就是它的“字面類型”

const obj = { a: 1, b: '2' }           

複制

上面的代碼顯然

obj

的類型會被自動推導成:

interface Obj {
  a: number
  b: string
}           

複制

但有時候希望

obj

的類型就是它的字面意思:

interface Obj {
  a: 1
  b: '2'
}           

複制

可以這樣:

const obj = { a: 1, b: '2' } as const           

複制

const assertions

如何讓泛型不被自動推導,讓泛型為必填項

class Test<P> {
  constructor(data: P) {}
}
new Test({ a: 1 }) // P 會被自動推導成 { a: number }           

複制

有些時候我們希望别人在使用

Test

這個類的時候必須得顯式傳入一個泛型才能使用。可以這樣:

type NoInfer<T> = [T][T extends any ? 0 : never]
class Test<P = void> {
  constructor(
    data: NoInfer<P> & (P extends void ? 'You have to pass in a generic' : {}),
  ) {}
}

new Test({ a: ' ' }) // err Argument of type '{ a: string; }' is not assignable to parameter of type 'void & "You have to pass in a generic"'.
new Test<{ a: string }>({ a: ' ' }) // ok           

複制

利用

NoInfer

這個工具類,泛型函數也同理:

type NoInfer<T> = [T][T extends any ? 0 : never]
function test<P = void>(
  data: NoInfer<P> & (P extends void ? 'You have to pass in a generic' : {}),
) {}

test(1)
test<number>(1)           

複制