⌨️

TypeScriptで型変数に期待していない型推論がきいてしまうとき

3 min read

TL;DR

NoInfer のような物があればいいのだが,ないのでこれを使う

https://github.com/microsoft/TypeScript/issues/14829#issuecomment-322267089
export type NoInfer<T> = T & {[K in keyof T]: T[K]}

Background

ものは違いますが,ReactのCustom Hooksを作っていて詰まりました.
以下の例を見てください.

const f = <T, S>(fn: (arg: T) => S, v: T): [S, (arg: T) => void] => {
    let s = fn(v)

    const f = (arg: T): void => {
        s = fn(arg)
        console.log(s)
    }

    return [s, f]
}

// 実行
const [s, setS] = f((n: number) => n.toString(), 0)
setS(1) // エラー

やってることとしては,

  • 第1引数にT型の値を受け取りS型の値を返す関数を取る
  • 第2引数にT型である値initial state(初期値)を取る
  • 内部に変数sで保存し,tupleの2項目で返す関数に値がきたらそれを第1引数で受け取った関数で更新し内部の変数sを更新する

fという関数を作成し,それを使っているだけです.

言葉だと少しややこしいですが,useStateのsetterで入れた値が常に何らかの関数で変換されて保存されているイメージを持ってもらえれば十分です.

ところで,TypeScriptは型推論があるので,fを使うときに型変数<T, S>を明示的に与えなくても,fに与えた引数から推論してくれます.
実際useStateを使うときも,const [state, setState] = useState(0)statenumber型になりますよね.

なので,今回は引数に与えた関数と初期値の型推論から,TnumberSstring になる……と思いきや.実は少し事情が違います.

上のコードを実行すると,

setS(1) // エラー

ここで

Argument of type '1' is not assignable to parameter of type '0'

というエラーが出ます.つまり初期値で与えた0により,Tがリテラル型として推論されてしまっているっぽいわけです.

ここで,じゃあどうしてuseStateは単純な型変数の使用では0とかの値を渡してもリテラル型として推論されないのか…が不思議なところなのですが型定義を読んでもちょっと不明でした.ご存じの方ご教授いただけますと助かります🙇‍♂️
ちなみに今回のようにややこしいことをしているから,というわけではなく,

const g = <T>(t: T): T => { return t }
const t = g(0)

みたいなことをしてもtはリテラル型の0になります.そりゃそうだ.つまりどこかでsupertypeに型付けしている(letにしてる的な)部分があるはずなんですが,どこなんだろう…

Solution

今回はfに与える関数の型がすべての正解と見ていいわけなので,型推論を一部だけ弱める方法はないか…と最初はX extends Y ? Y : Yとかやってたんですがこれでは壊れてしまうので,いろいろと探したところ,

https://github.com/microsoft/TypeScript/issues/14829

このissueにあたりました.

曰く,

https://github.com/microsoft/TypeScript/issues/14829#issuecomment-320754731

Just an update - T & { } creates a "lower-priority" inference site for T by design. I would move this from the "definitely don't depend on this" column to the "it's probably going to work for the foreseeable future" column.

らしく(これはどこかで入った変更なのかな?),ただし T & {} だといろいろ問題があるため,

https://github.com/microsoft/TypeScript/issues/14829#issuecomment-322267089

T & {} only works when not using strictNullChecks.
I encountered a problem when T is undefined, because undefined is not assignable to undefined & {}.

I found that mapped types also defer inference, so i tried type InferLast<T> = Readonly<T>;
That does not work for types with index signatures like arrays.

My final solution is a mapped type intersected with the type itself:

export type NoInfer<T> = T & {[K in keyof T]: T[K]};

という,こちらを使うとめでたく型推論の優先度を落とすことができ,Tnumber に型付くというわけでした.

冒頭の動くようになった全体のコードはこちらです.

type NoInfer<T> = T & {[K in keyof T]: T[K]};

const f = <T, S>(fn: (arg: T) => S, v: NoInfer<T>): [S, (arg: T) => void] => {
    let s = fn(v)

    const f = (arg: T): void => {
        s = fn(arg)
        console.log(s)
    }

    return [s, f]
}

const [s, setS] = f((n: number) => n.toString(), 0)
setS(1)

おわり

Discussion

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