generated at
TypeScriptのGenericな関数型の型推論の挙動が、直打ちとtype alias経由とで異なる

質問takker
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/villagepump/TypeScript%E3%81%AEGeneric%E3%81%AA%E9%96%A2%E6%95%B0%E5%9E%8B%E3%81%AE%E5%9E%8B%E6%8E%A8%E8%AB%96%E3%81%AE%E6%8C%99%E5%8B%95%E3%81%8C%E3%80%81%E7%9B%B4%E6%89%93%E3%81%A1%E3%81%A8type_alias%E7%B5%8C%E7%94%B1%E3%81%A8%E3%81%A7%E7%95%B0%E3%81%AA%E3%82%8B/function-types.ts
function-types.ts
interface Reader { chunk: unknown; } type Parser<R extends Reader = Reader> = <R2 extends R>(reader: R2) => 2; interface TextReader { chunk: string; } interface ByteReader { chunk: Uint8Array; } const byte: Parser<ByteReader> = () => 2;
test3 はエラーになってくれるのに、 test1 test2 はエラーにならず代入に成功してしまう
function-types.ts
const test1: Parser<TextReader> = byte; type TestParser = Parser<TextReader>; const test2: TestParser = byte; // @ts-expect-error const test3: <R2 extends TextReader>(reader: R2) => 2 = byte;
type で函数型を定義すると、実際は代入できなくても代入できてしまう?

何がおかしいと思ってるのかがよくわからないnishio
byte の型 Parser<ByteReader> は展開すると <R2 extends ByteReader>(reader: R2) => 2 になるtakker
この型の値は <R2 extends TextReader>(reader: R2) => 2 型の値に代入できない
実際、 test3: <R2 extends TextReader>(reader: R2) => 2 byte を代入しようとするとエラーになる
ところが、 test1: Parser<TextReader> には代入できてしまう
Parser<TextReader> を展開すると <R2 extends TextReader>(reader: R2) => 2 になるから、 test3 と同じ型のはずなのに、エラーにならない
単純なスペルミスだったらいいんだけど
とりあえずもうすこし再現コード小さくするか
geminiによる解説wogikaze
> byte の型は Parser<ByteReader> です。 ByteReader Reader のサブタイプですが、 TextReader とは関係ありません。
> 本来なら、 Parser<ByteReader> Parser<TextReader> のサブタイプではないため、test1とtest2はエラーになるはずです。
> しかし、TypeScriptの型システムは、関数型の引数の型に関しては反変を考慮しません。つまり、 Parser<ByteReader> Parser<TextReader> に暗黙的に代入可能とみなされてしまいます。
ここの共変・可変の説明
>共変: 型Aが型Bのサブタイプ(Bを継承・実装している)なら、Aを含む型もBを含む型のサブタイプになる。
> 反変: 型Aが型Bのサブタイプなら、Aを含む型はBを含む型のスーパータイプ(Bを含む型を継承・実装している)になる。

contextual typesが関係しているのか?takker
Parser<...> を使ったときもエラーになるようにしたい

type Parser<R extends Reader = Reader> = <R2 extends R>(reader: R2) => 2; <R2 extends R> の部分がなんのためにあるのかnishio
そこを削ったらこうなるけどこれが期待してた挙動なのでは?
あー、なるほど、理解した
const f : (a:never)=>number = (a:number)=>a // no error
反変にもすでに言及があるけど「広い範囲の値を取る関数は、狭い範囲の変数に代入しても実行時にエラーになることはない」のでTSの型チェックではそれはエラーにならない
ここで <R2 extends R> と書いてることによって、全ての型のサブタイプである never もR2になりえるようになってる
あー、そういう挙動になっちゃうかtakker
neverこわい
(reader: never) => 2 には任意の (reader: T) => 2 が代入できる
とここまで説明してから検証しようとしたができなかったw
なんとtakker
理解してなかった!

目的を話した方がすっきりしそうtakker
Genericなparser combinatorを作ろうとしたのが元々の目的
その過程で冒頭の問題が生じた

parser combinatorの略説
string-only.ts
type ParserInput = string; interface ParserOk<A> { ok: true; parsed: A; rest: ParserInput; } interface ParserErr { ok: false; expected: string; rest: ParserInput; } type ParserResult<A> = ParserOk<A> | ParserErr; type Parser<A> = (input: ParserInput) => ParserResult<A>; export const text = <S extends string>(value: S): Parser<S> => (input) => input.startsWith(value) ? { ok: true, value, rest: input.slice(value.length) } : { ok: false, expected: value, rest: input }; export const and = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<[A, B]> => (input) => { const result = parseA(input); if (!result.ok) return result; const result2 = parseB(result.rest); if (!result2.ok) return result2; return { ok: true, value: [result.value, result2.value], rest: result2.rest, }; }; export const or = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A | B> => (input) => { const result = parseA(input); if (result.ok) return result; return parseB(input); };
text , and , or を使って、文字列のparserを作れる
eg-only.ts
import { text, and, or } from "./string-only.ts"; const hoge = text("hoge"); const huga = text("huga"); const foo = text("foo"); const bar = text("bar"); /** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */ const parse = and( or(hoge, foo), or(huga, bar), ); // 実行 console.log(parse("hogebar"));
TODO: 単体テスト書く
このように小さなparserを組み合わせて、複雑なparserを作り上げる方法をParser combinatorと呼ぶ

さて、ここで入力データを任意の型 T にすることを考える
文字列のほかに、バイナリデータ Uint8Array などもparseできるようになる
Iteratorのような、データが完全に取得できる前に逐次parseするようなparserも書けるようになる
ReadableStreamなどはPromiseが絡むのでこれだけでは難しいが、少し工夫すればできるはず
眠いので続きはまた今度takker
必要なもの
任意の型 Input
Input から任意の長さのデータを読み取る関数 shift
string-only.ts String.slice() で行っていた処理を抽象化したもの
読み出したデータが Input と同じであるとは限らないので、 Chunk という別の型を用意する
例えば、 string[] から呼び出すとき、結果をjoinして string として返すなど
併せて、残りの解析対象データを Input として返す
色々実装法がある
shift の型から Input , Chunk を取り出す & Parser の定義時にすべての型を確定させる
2024-09-27 13:13:48 これ動きません。型をミスってますtakker
後ほど直します
generic-types-1.ts
export interface Reader { shift: (input: unknown, length: number) => [unknown, unknown]; } export type Input<R extends Reader> = R["shift"] extends (input: infer I, length: number) => [infer _, I] ? I :never; export type Chunk<R extends Reader> = R["shift"] extends (input: infer _, length: number) => [infer C, _] ? C :never; interface ParserOk<A, R extends Reader> { ok: true; parsed: A; rest: Input<R>; } interface ParserErr<I, R extends Reader> { ok: false; expected: string; rest: Input<R>; } export type ParserResult<A, R extends Reader> = ParserOk<A, R> | ParserErr<R>;
generic-1.ts
import type { Input, ParseResult, Reader } from "./generic-types-1.ts"; type Parser<A, R extends Reader> = (input: Input<R>, reader: R) => ParserResult<A, R>; export const text = <S extends string, R extends Reader>(value: S): Parser<S, R> => (prev, { shift }) => { const [shifted, next] = shift(prev, value.length); return shifted === value ? { ok: true, value, rest: next } : { ok: false, expected: value, rest: prev }; export const and = <A, B, R extends Reader>(parseA: Parser<A, R>, parseB: Parser<B, R>): Parser<[A, B], R> => (prev, reader) => { const result = parseA(prev, reader); if (!result.ok) return result; const result2 = parseB(prev, reader); if (!result2.ok) return result2; return { ok: true, value: [result.value, result2.value], rest: result2.rest, }; }; export const or = <A, B, R extends Reader>(parseA: Parser<A, R>, parseB: Parser<B, R>): Parser<A | B, R> => (prev, reader) => { const result = parseA(prev); if (result.ok) return result; return parseB(prev); };
eg-1.ts
import { text, and, or } from "./generic-1.ts"; const textReader = { shift: (input: string, length: number): [string, string] => [ input.slice(0, length), input.slice(length), ]; }; type TextReader = typeof textReader; const hoge = text<"hoge", TextReader>("hoge"); const huga = text<"huga", TextReader>("huga"); const foo = text<"foo", TextReader>("foo"); const bar = text<"bar", TextReader>("bar"); /** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */ const parse = and( or(hoge, foo), or(huga, bar), ); // 実行 console.log(parse("hogebar", textReader));
任意の型を受け取れるようになったが、 Parser を作るときに Reader を明示しなければならなくなった
text<"hoge", TextReader>("hoge") のような記法
書きづらい
引数から推論できるはずの "hoge" まで明示しなければならないのがつらい
そこで、 R extends Reader を関数型に埋め込み、型推論をparser利用時まで遅延させる
generic-2.ts
import type { Input, ParseResult, Reader } from "./generic-types-1.ts"; type Parser<A> = <R extends Reader>(input: Input<R>, reader: R) => ParserResult<A, R>; export const text = <S extends string>(value: S): Parser<S> => (prev, { shift }) => { const [shifted, next] = shift(prev, value.length); return shifted === value ? { ok: true, value, rest: next } : { ok: false, expected: value, rest: prev }; export const and = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<[A, B]> => (prev, reader) => { const result = parseA(prev, reader); if (!result.ok) return result; const result2 = parseB(prev, reader); if (!result2.ok) return result2; return { ok: true, value: [result.value, result2.value], rest: result2.rest, }; }; export const or = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A | B> => (prev, reader) => { const result = parseA(prev); if (result.ok) return result; return parseB(prev); };
eg-2.ts
import { text, and, or } from "./generic-2.ts"; const textReader = { shift: (input: string, length: number): [string, string] => [ input.slice(0, length), input.slice(length), ]; }; const hoge = text("hoge"); const huga = text("huga"); const foo = text("foo"); const bar = text("bar"); /** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */ const parse = and( or(hoge, foo), or(huga, bar), ); // 実行 // ここで`R`が`typeof textReader`確定する console.log(parse("hogebar", textReader));
eg-2.ts のようなコードなら type Parser<A> = <R extends Reader>(input: Input<R>, reader: R) => ParserResult<A, R>; で十分
しかし、 R にさらに型制約をかけたい状況があった
続きはまた今度takker


Simple Version
ts
interface TypeBase {} interface TypeA { chunk: "A"; } interface TypeB { chunk: "B"; } type Gen<R extends TypeBase> = <R2 extends R>(x: R2) => x; type Gen2<R2 extends TypeBase> = (x: R2) => x;
=> x => 2
ts
const oA: Gen<TypeA> = (x) => x; const test1: Gen<TypeB> = oA; // @ts-expect-error const test3: <R2 extends TypeB>(x: R2) => x = oA; // @ts-expect-error const test4: Gen2<TypeB> = oA; // これが期待した挙動だよね

test4 がerrorになるのはわかるのですがtakker
(reader: TypeB) => 2 の引数に代入できるが <R extends TypeA>(reader: R) => 2 の引数にどうやったって代入できない型が存在する
test1 がエラーにならないのに test3 がエラーになるのがわからないです
oAでなぜか引数を省略してるけどちゃんと書くとnishio
この型はgenericsになってるから R2 extends TypeA であるようなR2の中で R2 extends TypeB であるような型があるならエラーにならないのが正しいと思う
具体的にはneverはそれだよねと思う
はーんそういうことかtakker
しかし、そうすると test3 がエラーになるのはなぜだろうtakker
Gen<TypeB> <R2 extends TypeB>(reader: R2) => 2 は同じ型のはずだけど
これが下の記事に書いてあることかな(まだ読んでない関係なかった)
同じ型ではないのでは?nishio
<R2 extends TypeB>(reader: R2) => 2 Gen2<TypeB> と同じ型だと思ってて、なのでtest4を作って期待通りエラーになった
えっ同じじゃない?まじですかtakker
初歩的な誤認だったのか
確認
Gen<TypeB>
type Gen<R extends TypeBase> = <R2 extends R>(x: R2) => 2; だから、 R TypeB を代入して <R2 extends TypeB>(reader: R2) => 2 になる
Gen2<TypeB>
type Gen2<R2 extends TypeBase> = (x: R2) => 2; だから、 R2 TypeB を代入して (x: TypeB) => 2 になる
↑は↓こう書いたほうがもっとシンプルに確認できるのではmrsekut
ts
interface TypeB { chunk: 'B'; } // 要はこの2つの挙動に差異があることを確認したいように見える type Gen = <R2>(x: R2) => 2; type Gen2<R2> = (x: R2) => 2; const oA: Gen = x => 2; const test1: Gen = oA; // test3とtest4は同じなので省略 // @ts-expect-error const test4: Gen2<TypeB> = oA;
要はこの2つの挙動に差異があることを確認したい(で合っている?)
ts
type Gen = <R2>(x: R2) => 2; type Gen2<R2> = (x: R2) => 2;
この2つの挙動が違うのは理解できます(たぶん)takker
nishioさんはそのように認識しているみたいですtakker
ただtakkerはそうではなくて、↓の test1 が通るのに test3 がエラーになる原因を知りたいです
ts
type Gen3<R> = <R2 extends R>(x: R2) => 2; type TypeA = { chunk: "A" }; type TypeB = { chunk: "B" }; const oA: Gen3<TypeA> = (_) => 2; const test1: Gen3<TypeB> = oA; // @ts-expect-error const test3: <R2 extends TypeB>(x: R2) => 2 = oA;
たしかに、だいぶ謎だ。展開するかどうかで挙動が変わっているように見えるmrsekut
2点確認ポイントがありそう、順番に潰したい
健全な型システムの場合、あるべき(?)挙動は〇〇である
↑test1 or test3
ここは関数のvariantの話だmrsekut
Gen3<TypeA> Gen3<TypeB> のサブタイプの関係を確認する必要がある
ただ、今回の場合、 TypeA TypeB にはサブタイプの関係はない
なので test3 があるべき挙動、のはずだけど……takker
ですよねmrsekut
本来はtest3があっているはずなのに、test1がそうならないのはなぜか
ts
/** * AがBのサブタイプかどうかを判定する * e.g. IsSubType<'hoge', string>; // 'hoge' <: string なので true */ type IsSubType<A, B> = A extends B ? true : false; type Gen<R> = <R2 extends R>(x: R2) => 2; type TypeA = { chunk: "A" }; type TypeB = { chunk: "B" }; type OA = Gen<TypeA>; type Test1 = IsSubType<OA, Gen<TypeB>>; // true. なぜ? type Test3 = IsSubType<OA, <R2 extends TypeB>(x: R2) => 2>; // false. そうだね
typescriptってそういう一貫性のない挙動をすることがよくあるから、それの一例なのかもしれない
なにこの挙動takker
TypeScriptこわい
type aliasにするかどうかで変わる点が本件と一緒
もしかしたらtypescriptのrepoでissueが立っているかもしれませんtakker
ただ検索キーワードがわからない……
僕もそう思ってちょっと探してみましたが、issueが多すぎて(?)見つけられなかったmrsekut
wontfixでcloseされてるかもしれないnishio
IsSubType便利!nishio
そしてだいぶ問題がシンプルになった
mrsekutさんありがとうtakker
TypeAとTypeBに互換性がないのに、それぞれをGenで包むと互換性が出るのはなぜか
R2が共通のサブタイプneverになりうるからじゃない?というのが仮説
それを検証する手段がわからない
それと別にGenをtypeで名付けた場合と、名付けないで直接同じ内容を書いた場合に振る舞いが違うという問題がある
こちらはまったく謎
いや?これTypeBで実体化されたやつではなく、まだジェネリクスの状態なのか?
いや、それは元のでも同じか
Github Copilot Chatに聞いてみたtakker
Github Copilot Chat
test1 が通り、 test3 で型エラーが発生する理由は、TypeScriptの型推論と型の互換性に関する違いによるものです。具体的には、ジェネリック型の制約と型推論の違いが影響しています。
(略上記と同じコード)
詳細な説明
test1 について
Gen3<TypeB> は型引数 R TypeB を渡したジェネリック型です。そのため、 <R2 extends TypeB>(x: R2) => 2 という関数のシグネチャになります。 oA は型引数 R TypeA を渡したジェネリック型です。 Gen3<TypeA> <R2 extends TypeA>(x: R2) => 2 というシグネチャを持っています。
TypeScriptでは、ジェネリック型の互換性は「構造的部分型」に基づいて評価されます。 TypeA TypeB の部分型であるため、 Gen3<TypeA> Gen3<TypeB> と互換性があります。よって、 test1 は型エラーになりません。
> TypeA TypeB の部分型である
これ違いそうmrsekuttakker
そしたらなおさら違うじゃん
2回くらいおかしいと指摘したらtakker
>おっしゃる通りです。TypeAとTypeBは互いに代入可能な関係にはありません。以前の説明は誤りでした。正しい説明は以下の通りです。
(略)
> test1 が通るのはおかしいです。正しくは、 test1 test3 も型エラーが発生するべきです。おそらく、TypeScriptのバージョンや設定に起因するバグや特殊な挙動があるかもしれません。
と説明を撤回した
test3 について
test3 の宣言では、具体的な型シグネチャ <R2 extends TypeB>(x: R2) => 2 を使用しています。このシグネチャと Gen3<TypeA> のシグネチャを直接比較すると、 TypeB TypeA の部分型ではないため、互換性がないと見なされます。したがって、型エラーが発生します。
まとめ
Gen3<TypeB> Gen3<TypeA> と互換性がありますが、具体的な型シグネチャを直接比較する場合は、互換性がないと判断されます。これが test1 が通り、 test3 が型エラーになる理由です。
そんなことある??takker
手に終えそうになくなってきたtakker
vim-jpでも聞こう
名前を簡潔にしたver.
type aliasにするかどうかで挙動が変わる報告を見つけた
この件はbug fixされたようだ
(途中でnishioさんと認識ずれてそうだと気づいた。早めに聞けばよかったか)



UInt8Arrayとかstringとか全く無関係で、単にgenericsの書き方が適切でないだけ
当初は Gen2 のように実装したのですが、それでは対処しづらいケースが現れたため Gen のような形式に切り替えましたtakker
具体的には後日書きます

GenとGen2でどうして挙動が変わるのかについては...
なるほど?nishio
ちかそうなよかんtakker
とりあえず「実装の都合で特殊な処理になってる」という理解をしたnishio

今回は無関係だった
true でも false でも同じ結果になった
なにこれこわいtakker

ts
interface A { a: number; } interface B { a: string; } type IsSubType<A, B> = A extends B ? true : false; type AB = A & B; type aba = IsSubType<AB, A> // true type abb = IsSubType<AB, B> // true { type Gen<R> = <R2 extends R>(x: R2) => 2; type Test1 = IsSubType<Gen<A>, Gen<B>> // true type Test2 = IsSubType<Gen<B>, Gen<A>> // true type Test3 = IsSubType<Gen<A>, <R2 extends B>(x: R2) => 2> // false type Test4 = IsSubType<<R2 extends A>(x: R2) => 2, Gen<B>> // false type Test5 = IsSubType<<R2 extends A>(x: R2) => 2, <R2 extends B>(x: R2) => 2> // false } { type Gen<R> = <R2 extends R>() => R2; type Test1 = IsSubType<Gen<A>, Gen<B>> // true type Test2 = IsSubType<Gen<B>, Gen<A>> // true type Test3 = IsSubType<Gen<A>, <R2 extends B>() => R2> // false type Test4 = IsSubType<<R2 extends A>() => R2, Gen<B>> // false type Test5 = IsSubType<<R2 extends A>() => R2, <R2 extends B>() => R2> // false }
うーんnishio
返り値の型にしても関係ない
Genericsを展開すると挙動が変わる
両方展開してもfalse
なにもわからない...


これだなmiyamonz
>In cases where generic arguments haven't been instantiated they are substituted by any before checking compatibility:
ts
let identity = function<T>(x: T): T { // ... } let reverse = function<U>(y: U): U { // ... } identity = reverse; // Okay because (x: any)=>any matches (y: any)=>any
この主張の根拠がどこにあるか(公式docかgithub上での記載など)は調べてないです。分からんmiyamonz
でもたぶん合ってるんでしょうmiyamonz
こんな変換が挟まってしまうのかtakker

たぶんこういうことですmiyamonz
Genを経由すると、この挙動によってanyを経由するのでエラーが起きない
Genを経由しないと、型推論が素直に動き、高階型でインスタンス化されてない型の代入可能性をチェックしてエラーで落ちる

ちなみに、IsSubTypeでtrue or falseにするとエラーの原因が追いづらいので、素直に代入するとよいですよ
これはエラー文のそのままそのとおりで、R2はgenericなtypeでインスタンス化できないからそもそも判定しようがない

あと、ここまで上記は素朴に置換可能な記述で結果が異なることに対する調査ですが、
対して、そもそもじゃあどう書くべきだったか、という視点では、
TSで型レベルの高階関数はむずい
type Gen<T> = <T2 extends T>()=>T2 これは、Tを受けて、T2を受けたら()=>T2を返す型 なので高階型になってる
そもそも、typeの記述だと無理なやつ
どうしてもやりたいなら、interfaceを駆使しよう
これってhotscriptが使っているthisハックのことですか?takker
そうです!miyamonz
同じハックですがこれも分かりやすいです
ありがとうございます!takker
すでにlike押してた記事だったw
という話がありますmiyamonz
ここいらがTypescriptの限界みたいですね……調査ありがとうございますtakker
interfaceとthisで頑張るのも一応TSの限界の内なので頑張ってみるのは面白いかもです、頭こんがらがりそうですがmiyamonz

長いので誰か要約して(他力本願)Mijinko_SD
コンパイル時計算/型パズルはいいぞ。でも用法・容量に注意takker