TypeScriptのConditional Typesで引数に応じた型を選択的に出しわける

趣味のライブラリ開発を行っていたときに試行錯誤したはなしです。せっかくなのでブログにまとめました。

たとえば以下の仕様を満たすクラス Dispatcher を作ります。

  • イベントを購読したり発行するDispatcherクラス
    • 複数のイベントと、それに対応する処理を subscribe メソッドと publish メソッドで登録できます
  • subscriberを登録する subscribe メソッド
    • subscriber は、イベントの種別とコールバック関数を持ちます
  • イベントを配信する publish メソッド
    • publish時にイベントに応じた引数(イベント変数)をセットすることで、subscriberのコールバック関数に引数を渡しつつ呼び出します
    • イベントごとにイベント変数の型が決定されます

これらの仕様を満たすように、こんなかんじのクラスと型を定義します。

type EventBase = {name: string}
type Subscriber = {
  event: EventBase,
  callback: (params: any) => void
}

class Dispatcher {
  private subscribers = new Map<string, Array<Subscriber>>()

  publish(event: EventBase, params: any) {
    // subscribers に登録されたイベントのうち、eventに一致するsubscriberを取り出して
    // コールバック関数を呼び出す
    this.subscribers.get(event.name)?.forEach(item => item.callback(params))
  }

  subscribe(item: Subscriber) {
    // subscriebrs に subscriber を登録する
    this.subscribers.has(item.event.name) ?
      this.subscribers.get(item.event.name)?.push(item) :
      this.subscribers.set(item.event.name, [item])
  }
}

subscribeでイベントを登録します。

const GetDocumentEvent = {name: 'GetDocument'}

const subscriber = {
  event: GetDocumentEvent,
  callback: (params: any) => {console.log('Parameter :', params)}
}

dispatcher.subscribe(subscriber)

publish でイベントを配信します。先ほど登録した subscriber のコールバック関数にpublishメソッドの引数 params をコールバック関数の引数にセットして呼び出します。 (以降、subscriberのcallback関数の引数を「イベント変数」と呼ぶことにします)

// subscriber.callback が params = {id: 1} の引数を渡されつつ呼び出される
dispatcher.publish(GetDocumentEvent, {id: 1})

// 実行結果: Parameter: {id: 1} がプリントされる

ここまでの実装を TypeScript Playground で確認できます。

ところで、Dispatcher クラスに着目すると、publishメソッドのparamsが any型です。このparams(イベント変数)は、publishするイベントによって型を固定したいのですが、このままだと eventとparamsの型の対応が記述できていないので、どんな型の変数を代入してもエラーになりません。

どうにかして、publish メソッドの params引数に、対応するイベントのイベント変数型と一致しない値を与えたときは型エラーになるように定義したいです。

const GetDocumentEvent = {name: 'GetDocument'}
type GetDocumentEventSubscriber = {
  event: EventBase,
  callback: (params: {id: number}) => void
}

const PutDocumentEvent= {name: 'PutDocument'}
type PutDocumentEventSubscriber = {
  event: EventBase,
  callback: (params: {id: number, content: string}) => void
}

// [TODO] Dispatcher クラスをいい感じに定義する

dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'bar'})

dispatcher.publish(GetDocumentEvent, {id: 1, content: 'bar'}) // Type Error にしたい

イベント型とイベント変数型をDispatcherクラスに複数登録できるようにする

「イベントに応じて」pubishメソッドのparamsの型を決定するには、まずは各イベントを適切に型として定義しつつ、Dispatcherクラスがこのイベント型たちを知っている必要があります。

EventBaseと型が一致するように、こんな感じにイベントを定義してみます。

type EventBase = {name: string}

// as const を付けないと typeof の結果が {name: string} となる
// as const を付けることで typeof の結果が {name: 'GetDocument'} となる
const GetDocumentEvent = {name: 'GetDocument'} as const
const PutDocumentEvent = {name: 'PutDocumentEvent'} as const

type GetDocumentEventType = typeof GetDocumentEvent 
type PutDocumentEventType = typeof PutDocumentEvent

paramsの型も同様に定義してみます。

// PutDocumentEvent に対応するイベント変数型
type GetDocumentEventParams = {
  id: number
}

// PutDocumentEvent に対応するイベント変数型
type PutDocumentEventParams = {
  id: number,
  content: string
}

イベントとparamsの型を紐づけられるように、Subscriber型を書き直し、イベント型とイベント変数型を指定できるようにします。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void
}

上記の定義を使ってイベントごとのSubscriber型を定義します。

type GetDocumentEventSubscriber = Subscriber<GetDocumentEventType , GetDocumentEventParams>
type PutDocumentEventSubscriber = Subscriber<PutDocumentEventType, PutDocumentEventParams>

これで「イベント型とイベント変数型の対応関係」が表現できるようになりました。これを Dispatcher形に与えられるようにするために、ジェネリクス型Sを定義します。 ( extends で 型SはSubscriberを拡張したものであると明示します)

また、Dispatcher内のsubscribersに指定する型も、Sに置きなおします。

- class Dispatcher {
+ class Dispatcher<S extends Subscriber<EventBase, any>> {
-    private subscribers = new Map<string, Array<Subscriber>>()
+    private subscribers = new Map<string, Array<S>>()

  // ...省略...

-    subscribe(item: Subscriber) {
+    subscribe(item: S) {
}

ここまでの実装で、「Dispatcherクラスがつかうイベント型およびイベント変数型を定義」できました。インスタンス化するときはこのように、Dispatcherクラスの中で使いたいSubscriber型のUnion Typeを与えます。

// Dispatcherで使うSubscriber型のUnion Type
type DocumentEventSubscriberTypes  =
  GetDocumentEventSubscriber |
  PutDocumentEventSubscriber

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

ここまでの実装は TypeScript Playground で確認できます。

Conditional Types でUnion Typeから特定のSubscribe型を抽出する

publishメソッドにおいて、「与えられたイベント型」を取得できるように修正します。以下のように書くことで、コンパイラが決定した引数 event の型をEとして参照できるようになります(TypeScriptのリファレンス に詳しい解説があります)。

-  publish(event: EventBase, params: any) {
+  publish<E extends SubscribedEvents<S>>(event: E, params: any) {

最終的に活用したいイベント変数型(=publishメソッドのparams変数の型)はSubscriber型のcallbackメンバが(型Pとして)持っています。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void   // <-- このPを publishメソッドのparamsの型としてつかいたい
}

また、Dispatcherクラスにはジェネリクス型Sとして、Dispatcherクラスの中で使われ得るSubscriber型のUnion Typeが渡されていることを思い出します。 これらを活用していい感じにするために、まずはジェネリクス型Sから与えられたイベント型に対応するSubscriber型を抽出する方法を考えてみます。

export type Subscriber<E, P> = {
  event: E,      // <-- 与えられたイベント型とEが一致するSubscriber型を
                 //     Subscriber型のUnion Type(Dispatcherのジェネリクス型S)から抽出したい
  callback: (params: P) => void
}

TypeScriptには「与えられた型に応じて型を決定する」というロジックをかける Conditional Types という仕組みがあります。

www.typescriptlang.org

T型がU型に含まれる場合はFoo型はS型となり、そうでない場合はV型になります。

type Foo = T extends U ? S : V

例えば以下のように書くことができます。

type Hoge = 'hoge'
type Foo = Hoge extends string ? true : false  // 結果はTrue型になる
type Bar = Hoge extends number ? true : false  // 結果はFalse型になる

これは以下のようにもかけます。

type Foo<T> = T extends string ? true : false

type Hoge = Foo<'hoge'>         // 結果はTrue型
type Fuga = Foo<1>              // 結果はFalse型

const str = 'abc'
type Piyo = Foo<typeof str>     // 結果はTrue型

さらに、以下のようにUnion Typeを与えた場合、Union Typeごとに条件が評価されます。

type Foo<T> = T extends string ? true : false

type Hoge = Foo<'abc' | 123>   // 結果は(True | False) 型。つまりboolean型。

// 上記は以下のように書いたときと同じ結果
type Fuga = Foo<'abc'> | Foo<123>

これを利用すると、あるUnion Typeから特定の型を含む型を抽出できます。SubscriebrのUnion TypeであるSのうち、イベント型Eを含む型を抽出する型定義は以下のように書けます。

実行結果は TypeScript Playgroundで確認できます。

type SubscribesFromEvent<S, E> = S extends {event: E} ? S : never

// Test1型は GetDocumentEventSubscriber 型になる
type Test1 = SubscribesFromEvent<DocumentEventSubscriberTypes, GetDocumentEventType>  
// Test2型は PutDocumentEventSubscriber 型になる
type Test2 = SubscribesFromEvent<DocumentEventSubscriberTypes, PutDocumentEventType>

ちなみに、「あるUnion Typeから特定の型を含む型を抽出」するユーティリティ型 Extract<Type, Union> がTypeScriptでは標準で定義されています。

www.typescriptlang.org

これを使うと以下のように書き直すこともできます。

// Test1型は GetDocumentEventSubscriber 型になる
type Test1 = Extract<DocumentEventSubscriberTypes, {event: GetDocumentEventType}>
// Test2型は PutDocumentEventSubscriber 型になる
type Test2 = Extract<DocumentEventSubscriberTypes, {event: PutDocumentEventType}>

つまり、ユーティリティ型 Extract<Type, Union> の定義は以下と同様です。

type Extract<T, U> = T extends U ? T : never

これで、「Subscribe型のUnion Typeのうち、あるイベント型から特定のSubscribe型を抽出」できました。

つぎは、抽出したSubscriber型のうち、イベント変数型Pを抽出する方法を考えます。

Subscribe型からイベント変数型Pを抽出する

publishメソッドのイベント型を決定するためには、Subscriber型から以下のP型を抽出する必要があります。これも Conditional Types を使います。

export type Subscriber<E, P> = {
  event: E,
  callback: (params: P) => void   // <-- このPを publishメソッドのparamsの型としてつかいたい
}

Conditional Types では infer 句によって、extends 句で比較したときに解決された型を戻り型として使うことができます。(関連ドキュメント

type Foo<T> = T extends (p: infer Params) => void ? Params : never

type Hoge = Foo<(param: string) => void>   // Hoge は string型
type Fuga = Foo<(param: number) => void>   // Fuga は number 型
type Piyo = Foo<string>                    // Piyo は never型

これを利用して、以下のような型を定義してみます。

// S['callback'] と書くとS型のうちcallbackメンバの型を取得できる
// https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
// 
// S['callback'] を参照できるようにするため、'S extends Subscriber<EventBase, any>' と記述し
// S型がSubscriber型の拡張型であることを明示する
type ParamsFromSubscriber<S extends Subscriber<EventBase, any>> =  S['callback'] extends (p: infer Params) => void ? Params : never

このようにするとSubscriber型からイベント変数型を抽出できます。

実行結果は TypeScript Playgroundで確認できます。

// GetDocumentEventParams 型になる
type Test1 = ParamsFromSubscriber<GetDocumentEventSubscriber>

// PutDocumentEventParams 型になる
type Test2 = ParamsFromSubscriber<PutDocumentEventSubscriber>

ここまでくれば、あとはこれらを組み合わせることで問題が解決できそうです。

イベント型に応じてイベント変数型を決定する

これまでで定義した、Subscriber型のUnion Typeからイベント型に基づきSubscriber型を一つ決定する SubscribesFromEvent 型と、 Subscriber型で使われているイベント変数型を抽出する ParamsFromSubscriber 型を組み合わせて、 CallbackParamsFromEvent 型を定義します。

type CallbackParamsFromEvent<S extends Subscriber<EventBase, any>, E extends EventBase> = ParamsFromSubscriber<SubscribesFromEvent<S, E>>

CallbackParamsFromEvent 型を使うことで、あるイベント型からSubscriber型のUnion Typeを参照してイベント変数型を決定できるようになりました。 Dispatcherクラスのpublishメソッドを以下のように書き直します。

-  publish<E extends SubscribedEvents<S>>(event: E, params: any) {
+  publish<E extends SubscribedEvents<S>>(event: E, params: CallbackParamsFromEvent<S, E>) {

これで any型を排除することができました。最終的な実装結果は TypeScript Playground を確認してください。

以下のように型を活用できていることがわかると思います。

/* GetDocumentEvent */
const GetDocumentEvent = {name: 'GetDocument'} as const
type GetDocumentEventType = typeof GetDocumentEvent 
type GetDocumentEventParams = {
  id: number
}
type GetDocumentEventSubscriber = Subscriber<GetDocumentEventType, GetDocumentEventParams>

/* PutDocumentEvent */
const PutDocumentEvent = {name: 'PutDocumentEvent'} as const
type PutDocumentEventType = typeof PutDocumentEvent
type PutDocumentEventParams = {
  id: number,
  content: string
}
type PutDocumentEventSubscriber = Subscriber<PutDocumentEventType, PutDocumentEventParams>

export type DocumentEventSubscriberTypes  =
  GetDocumentEventSubscriber |
  PutDocumentEventSubscriber

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// 型チェックをパスする
dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'Content'})

// イベント型に紐づいたイベント変数型を与えていないので型エラーになる
dispatcher.publish(GetDocumentEvent, {foo: 1})
dispatcher.publish(PutDocumentEvent, {id: 1})

publish メソッドで登録できるイベントを制限する

このままだと、以下のように未登録のイベント型を登録できてしまいます。

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// DocumentEventSubscriberTypes に PatchDocumentEvent は含まれていないとすれば、
// 以下はType Errorになるようにしたい
dispatcher.publish(PatchDocumentEvent, {id: 1, content: 'Content'})

これまで使ったConditional Typesを活用し、以下の型を新たに定義します。

type SubscribedEvents<S extends Subscriber<EventBase, any>> = Pick<S, 'event'>['event']

Pick<T, U> は T型からU型を抽出した型を返却するユーティリティ型です。これを使って、Subscriber型のUnion Typeからイベント型を抽出します。

www.typescriptlang.org

以下のようにpublishメソッドのイベント型を絞り込むことで、S型として指定したSubscriber型のUnion Typeに含まれないイベント型は登録できなくなります。

  class Dispatcher<S extends Subscriber<EventBase, any>> {
    private subscribers = new Map<string, Array<S>>()

-   publish<E extends EventBase>(event: E, params: CallbackParamsFromEvent<S, E>) {
+   publish<E extends SubscribedEvents<S>>(event: E, params: CallbackParamsFromEvent<S, E>) {
      this.subscribers.get(event.name)?.forEach(item => item.callback(params))
    }

  // ... 省略 ....
}

実行結果はTypeScript Playgroundで確認できます。

const dispatcher = new Dispatcher<DocumentEventSubscriberTypes>()

// 型チェックをパス
dispatcher.publish(GetDocumentEvent, {id: 1})
dispatcher.publish(PutDocumentEvent, {id: 1, content: 'Content'})

// 未登録の PatchDocumentEvent を渡したためType Error
dispatcher.publish(PatchDocumentEvent, {id: 1, content: 'Content'})

まとめ

Conditional Typesを活用することで、与えられた型に応じて型の場合分けをすることができます。 また、既存の型定義から型を抽出したり選択することで新たな型をプログラマブルに定義できます。 うまくつかえば、型の定義をあきらめずにライブラリを記述できるはずです。

特に、ライブラリを自作する際には、型定義ができない場合は「型定義できるようにうまく実装する」ことを心掛けることにより、 型によってソースコードの取り扱い方が明確に示されることから、よりよいソースコードになるかもしれません。

ではでは~