TypeScriptで型変数に期待していない型推論がきいてしまうとき
TL;DR
NoInfer
のような物があればいいのだが,ないのでこれを使う
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)
でstate
はnumber
型になりますよね.
なので,今回は引数に与えた関数と初期値の型推論から,T
がnumber
, S
がstring
になる……と思いきや.実は少し事情が違います.
上のコードを実行すると,
setS(1) // エラー
ここで
Argument of type '1' is not assignable to parameter of type '0'
というエラーが出ます.つまり初期値で与えた0により,T
がリテラル型として推論されてしまっているっぽいわけです.
Solution
今回はf
に与える関数の型がすべての正解と見ていいわけなので,型推論を一部だけ弱める方法はないか…と最初はX extends Y ? Y : Y
とかやってたんですがこれでは壊れてしまうので,いろいろと探したところ,
このissueにあたりました.
曰く,
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 & {}
だといろいろ問題があるため,
T & {}
only works when not usingstrictNullChecks
.
I encountered a problem whenT
isundefined
, becauseundefined
is not assignable toundefined & {}
.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]};
という,こちらを使うとめでたく型推論の優先度を落とすことができ,T
が number
に型付くというわけでした.
冒頭の動くようになった全体のコードはこちらです.
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