⌨️

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

2021/09/08に公開

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がリテラル型として推論されてしまっているっぽいわけです.

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