TypeScript Tip #15

Apr 1, 2022

6 mins read

背景

Twitterを眺めていると汎用性が高そうな関数を作成できるTypeScriptのツイートがあったので、整理するためにまとめる。
TypeScript Tip #15

概要

関数の引数のタイプをみて、タイプに応じてpayloadが必要か必要がないかをTypeScriptで判断する。

// 第一引数がSIGN_OUTの場合は、payloadはなし
sendEvent('SIGN_OUT')

// 第一引数がLOG_INの場合は、payloadは必須
sendEvent("LOG_IN", {
    userId: '123'
})

成功パターン

sendEvent('SIGN_OUT')

sendEvent("LOG_IN", {
    userId: '123'
})

失敗パターン(コンパイルエラー)

sendEvent('SIGN_OUT', {})

sendEvent('LOG_IN', {
    userId: 122
})

sendEvent('LOG_IN', {})

sendEvent('LOG_IN')

本題

以下のコードで実現可能

type AuthEvent =
    | {
    type: 'LOG_IN'
    payload: {
        userId: string
    }
}
    | {
    type: 'SIGN_OUT'
}

const sendEvent = <Type extends AuthEvent['type']>(
    ...args: Extract<AuthEvent, { type: Type }> extends { payload: infer TPayload } 
        ? [Type, TPayload]
        : [Type]
) => {
    const [type, payload] = args
    // ...
}

処理の流れ

  1. ジェネリクスに制約をつける形でTypeの型を作成する。
    この時点でTypeLOG_INorSIGN_OUTのどちらかになる。
    ちなみにAuthEvent['type']のようにキーを指定して型定義を取得する方法をルックアップ型という。
<Type extends AuthEvent['type']>
  1. TypeScriptは関係ないが、...argsは引数を配列で格納する
...args
// Ex) ['SIGN_OUT'], ['LOG_IN', { userId: '123' }]
  1. Extract型でAuthEvent{ type: Type }に代入可能な型を取り除くことができる。
Extract<AuthEvent, { type: Type }>

1の時点でTypeLOG_INorSIGN_OUTにどちらかに決まっていたので、{ type: Type }{ type: 'LOG_IN' } or { type: 'SIGN_OUT' }のどちらかになる。
なので、上記のコードで以下のどちらかになる

{ 
    type: 'LOG_IN'
    payload: { userId: string } 
}

or

{
    type: 'SIGN_OUT'
}

Extract型については、以下の記事を参考にするとわかりやすいかも。
【TypeScript】Utility Typesをまとめて理解する

  1. 3でExtractの型はわかったので{ payload: infer TPayload }をが存在するかの条件分岐を行う。
    Extract型にpayloadが存在するかを確認して、[Type, TPayload]or[Type]の分岐を行う。
    また、inferは型はpayloadが存在していればTPayloadとして推論してくれる。 この時点でpayloadがある引数なのか、ない引数なのかの判定ができる。
Extract<AuthEvent, { type: Type }> extends { payload: infer TPayload }
    ? [Type, TPayload]
    : [Type]

inferについては以下の記事がしっくりきた。
TypeScriptのinferを今度こそちゃんと理解する

ちなみに失敗パターンは以下の型エラーになる

sendEvent('SIGN_OUT', {})
// Expected 1 arguments, but got 2.

sendEvent('LOG_IN', {
    userId: 122
    // Type 'number' is not assignable to type 'string'
})

sendEvent('LOG_IN', {})
// Argument of type '{}' is not assignable to parameter of type '{ userId: string; }'.
// Property 'userId' is missing in type '{}' but required in type '{ userId: string; }'.

sendEvent('LOG_IN')
// Expected 2 arguments, but got 1.

まとめ

うまく説明できていない気がするが、TypeScriptの復習になった。今後も続けていきたい。
Thank you Matt Pocock for your informative tweet. I learned a lot.

参考文献

TypeScript Tip #15
【TypeScript】Utility Typesをまとめて理解する
TypeScriptのinferを今度こそちゃんと理解する