😸

TypescriptのGenericsでanyに推論された型をany以外の型に強制する黒魔術について知りたい

2022/02/19に公開約5,000字

あらまし

拙作react-inner-hooks-extensionにおいて、コンポーネント上のpropsにセットされた関数の戻り値と指定されたpropsをマージするという仕組みを作った。この関数の戻り値の型を推論することによって残りのpropsの型も推論されるという仕組みを階層コンポーネントを使うことで実現することができた。

これを、jsxの拡張によって、componentのpropsにconnectContainerというpropsが指定されていた場合にこのhocを勝手に差し込むという最高にハジけた(傍迷惑かもしれない)ライブラリを作ったわけなのだが、困ったことに(有難いことに)ReactのComponentはそれ単体ではこのconnectContainerの有無をちゃんと明示的に型をつけてしないと受け付けてもらえない。そこでreactの型拡張ファイルをライブラリとして用意する必要があったのだが、ここで困ったことがでてきた。

簡便のためにFunctionalComponentの時だけ事例を紹介するが、基本的にはReactに自分で定義したpropsを任意に受け付けられるようにする場合、以下のような型拡張を施す必要がある。

export type EextendedComponentProps<Props> = Props & {
  ext?: any
}
export interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<EextendedComponentProps<P>>, context?: any): ReactElement<any, any> | null;
}

ただ、jsxでレンダリングする場合、今回作ったライブラリのような引数指定時に型推論を行いたいような場合でも変数に一度コンポーネントを定義しなければいけない。静的型付けはプリプロセスで処理されるため、代入時に型を正確に指定しなければいけない。当初以下のような型定義を作って適用していたわけなのだが、

export type RestProps<Props, PatialProps> = Omit<Props, keyof PatialProps>

export type AddInnerHooksProps<Props, IP = {}, RefValue = any> = RestProps<Props, IP> & {
  connectContainer?: (props: RestProps<Props, IP>, ref?: MutableRefObject<RefValue>) => IP
}

export interface FunctionComponent<P = {}> {
    <IP = {}, RefValue = any>(props: PropsWithChildren<AddInnerHooksProps<P, IP, RefValue>>, context?: any): ReactElement<any, any> | null;
}

これを実際のコンポーネントで実験したところ、本来は以下のように

const Example: FunctionComponent<{a: string}> = function Example({a: string}){
  return <></>  
}

<Example connectContainer={() => return {a:1}}

のconnectContainerを勝手に推論してほしいわけなのだけど、実際は

const Example: FunctionComponent<{a: string;}> = function Example({a: string; connectContainer?: SomeT}){
  return <></>  
}
<Example connectContainer={() => return {a:1}}

のように指定しないとエラーになる。理由は前者の例だとconnectContainerが右辺のpropsに存在していないので型定義のIPがanyで推論され、これにOmitが適用されると元のpropsで推論される型が全部持っていかれて、正しく型推論されないのだ。折角jsx拡張を導入して、既存のプロジェクトにhocを突っ込んでいく書き換えコストを最小限にしたいのに型指定を書き換えていくのでは意味がない。anyを{}で推論できるとconnectContainerを差し込むまでOmitされるものがないという推論を走らせることができるのでanyを{}で推論したいというのが今回のお話。

結論

type A<P extends {} = {}> = P extends Exclude<any, P> ? P : {}

こういうコードで動く。本当にたまたま色々なコード試していたらたまたま発見した。「これなんで何処にも情報ないの?この方法を見つけるまでに2時間くらい持っていかれたんですけど?ユーティリティに関わるタイプレベルで重要だと思うんだけど?なんで?」というただ単に情報検索能力が低かっただけかもしれない筆者の心の叫びは置いといて、これで上手くいく。なんでこれでうまくいくのかは納得行かなすぎたので、きちんとなんでこれでうまくいっているのか自分なりに検証して考えてみた。

TypescriptのExcludeユーティリティタイプは以下のようにAがBの派生型だったら、neverで除外、そうでなかったらA型をそのまま返すという挙動をする。

type Exclude<A, B> = A extends B ? never : A

では、

Exclude<any, B>

の挙動を考えてみよう。

B=anyの場合結果は間違いなくneverになる。それ以外の何かの型をBに突っ込むと結果はanyで返ってくる。何もおかしくない気はするが、anyは全ての型の基底型のように扱えるけど派生型のようにも扱えることと以上の結果は矛盾してないかと考え込んでしまった。以上の結果が真だとすると

let a: any = 'foo'

のように基底型として派生型の値を変数として受け入れられる事は直観に合っている。

let z: string = 1 as any

しかしながら、以上のように型システムを敢えて破壊するような挙動を保証しているという事はanyが全ての基底型でありながら、派生型として扱えないと先の結果と矛盾することにならないかと思い悩み、眠れない夜を一晩過ごすことが嫌だったので以下のような実験をしてみた。

any extends B ? 'a' : 'b'

B=anyの場合結果は間違いなく'a'になる。では、any以外の何かを指定した時はどうなるかの結果をTypescriptのplaygoundで見てみよう。

example-inference-1

'a' | 'b'

と推論されている。なんだこれ。ちなみにextendsのでこのような共用体型で判定される例は今のところ私が把握している事例だとanyを派生型チェックに入れた時だけ。つまり、typescriptのextendsで使われてる三項演算子のような演算子は一般的なプログラミング言語における2値判定の三項演算子のロジックとは厳密には同じではなく、そのどちらでもないという結果を出力するロジックが加えられている事になる。anyのextends判定をする時に限るが、こんな重要な事実を一体何処で人は知ることが出来るのかがわからないので記事に書くことにした。もし、元から知っていたよという方がいらっしゃったら、この情報の出どころを教えてください。

ただ、たしかにこの疑問の発端となったanyを全ての型の派生型として扱えないと代入のロジック組むのが必要以上に複雑になる気がしたので、これが落とし所なのかという気がした。お客さまの中に型理論の有識者はいらっしゃいませんか。

ちなみにneverを共用体にすると、never以外に型が決まる場合にはneverは打ち消される。

never | string -> string

先の例では

type A<P extends {} = {}> = P extends Exclude<any, P> ? P : {}

のPはPがanyの時はneverで{}に変換され、それ以外の型の場合はExclude<any, P>はanyと判定され、Pはなんであろうとanyの派生型として扱われるためPに推論される。めでたしめでたし。

おまけ

最終的なライブラリの改良後のコードは以下のようになった。

    export type AddInnerHooksProps<Props, IP = {}, RefValue = any> = RestProps<Props, ForceIP<IP>> & {
        connectContainer?: (props: RestProps<Props, ForceIP<IP>>, ref?: MutableRefObject<RefValue>) => ForceIP<IP>
    }

    export type ForceIP<IP> = IP extends Exclude<any, IP> ? IP : {}

こうすると、

const Example: FunctionComponent<{a: string}> = function Example({a: string}){
  return <></>  
}

<Example connectContainer={() => return {a:1}}

が、ちゃんと型推論が効くようになる。めでたしめでたし。

その後

jsx拡張にした所感

const A: FC<{a: ...}>  = function...
export default A
function A({a:...})...
export default withInnerHooks(A)

書く量変わってないし、props名に依存するから利用する側のコンポーネントはライブラリを外す場合はどちらにせよ書き直しにはなりそうだけど、コンポーネント内を切り出せるように汚さないという観点だと役に立ってそうかなという印象でした。

Discussion

ログインするとコメントできます