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

$ 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.tsinterface 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.tsconst 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
で函数型を定義すると、実際は代入できなくても代入できてしまう?
何がおかしいと思ってるのかがよくわからない

byte
の型
Parser<ByteReader>
は展開すると
<R2 extends ByteReader>(reader: R2) => 2
になる

この型の値は <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
と同じ型のはずなのに、エラーにならない
単純なスペルミスだったらいいんだけど
とりあえずもうすこし再現コード小さくするか

による解説

> 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を含む型を継承・実装している)になる。
Parser<...>
を使ったときもエラーになるようにしたい
type Parser<R extends Reader = Reader> = <R2 extends R>(reader: R2) => 2;
の
<R2 extends R>
の部分がなんのためにあるのか

そこを削ったらこうなるけどこれが期待してた挙動なのでは?
あー、なるほど、理解した
const f : (a:never)=>number = (a:number)=>a // no error
反変にもすでに言及があるけど「広い範囲の値を取る関数は、狭い範囲の変数に代入しても実行時にエラーになることはない」のでTSの型チェックではそれはエラーにならない
ここで <R2 extends R>
と書いてることによって、全ての型のサブタイプである never
もR2になりえるようになってる
あー、そういう挙動になっちゃうか

(reader: never) => 2
には任意の (reader: T) => 2
が代入できる
とここまで説明してから検証しようとしたができなかったw
なんと

理解してなかった!
目的を話した方がすっきりしそう

その過程で冒頭の問題が生じた
parser combinatorの略説
string-only.tstype 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.tsimport { 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: 単体テスト書く
さて、ここで入力データを任意の型 T
にすることを考える
文字列のほかに、バイナリデータ Uint8Array
などもparseできるようになる
Iteratorのような、データが完全に取得できる前に逐次parseするようなparserも書けるようになる
ReadableStreamなどはPromiseが絡むのでこれだけでは難しいが、少し工夫すればできるはず
眠いので続きはまた今度

必要なもの
任意の型 Input
Input
から任意の長さのデータを読み取る関数 shift
string-only.ts
で String.slice()
で行っていた処理を抽象化したもの
読み出したデータが Input
と同じであるとは限らないので、 Chunk
という別の型を用意する
例えば、 string[]
から呼び出すとき、結果をjoinして string
として返すなど
併せて、残りの解析対象データを Input
として返す
色々実装法がある
shift
の型から Input
, Chunk
を取り出す & Parser
の定義時にすべての型を確定させる
2024-09-27 13:13:48 これ動きません。型をミスってます

後ほど直します
generic-types-1.tsexport 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.tsimport 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.tsimport { 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.tsimport 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.tsimport { 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
にさらに型制約をかけたい状況があった
続きはまた今度

Simple Version
tsinterface 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になるのはわかるのですが

(reader: TypeB) => 2
の引数に代入できるが <R extends TypeA>(reader: R) => 2
の引数にどうやったって代入できない型が存在する
test1
がエラーにならないのに test3
がエラーになるのがわからないです
oAでなぜか引数を省略してるけどちゃんと書くと

この型はgenericsになってるから R2 extends TypeA
であるようなR2の中で R2 extends TypeB
であるような型があるならエラーにならないのが正しいと思う
具体的にはneverはそれだよねと思う
はーんそういうことか

しかし、そうすると
test3
がエラーになるのはなぜだろう

Gen<TypeB>
と <R2 extends TypeB>(reader: R2) => 2
は同じ型のはずだけど
これが下の記事に書いてあることかな(まだ読んでない関係なかった)
同じ型ではないのでは?

<R2 extends TypeB>(reader: R2) => 2
は Gen2<TypeB>
と同じ型だと思ってて、なのでtest4を作って期待通りエラーになった
えっ同じじゃない?まじですか

初歩的な誤認だったのか
確認
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
になる
↑は↓こう書いたほうがもっとシンプルに確認できるのでは

tsinterface 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つの挙動に差異があることを確認したい(で合っている?)
tstype Gen = <R2>(x: R2) => 2;
type Gen2<R2> = (x: R2) => 2;
この2つの挙動が違うのは理解できます(たぶん)


さんはそのように認識しているみたいです

ただ

はそうではなくて、↓の
test1
が通るのに
test3
がエラーになる原因を知りたいです
tstype 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;
たしかに、だいぶ謎だ。展開するかどうかで挙動が変わっているように見える

2点確認ポイントがありそう、順番に潰したい
健全な型システムの場合、あるべき(?)挙動は〇〇である
↑test1 or test3
ここは関数のvariantの話だ

Gen3<TypeA>
と Gen3<TypeB>
のサブタイプの関係を確認する必要がある
ただ、今回の場合、 TypeA
と TypeB
にはサブタイプの関係はない
なので
test3
があるべき挙動、のはずだけど……

ですよね

本来は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ってそういう一貫性のない挙動をすることがよくあるから、それの一例なのかもしれない
なにこの挙動

TypeScriptこわい
type aliasにするかどうかで変わる点が本件と一緒
もしかしたらtypescriptのrepoでissueが立っているかもしれません

ただ検索キーワードがわからない……
僕もそう思ってちょっと探してみましたが、issueが多すぎて(?)見つけられなかった

wontfixでcloseされてるかもしれない

IsSubType便利!

そしてだいぶ問題がシンプルになった

さんありがとう

TypeAとTypeBに互換性がないのに、それぞれをGenで包むと互換性が出るのはなぜか
R2が共通のサブタイプneverになりうるからじゃない?というのが仮説
それを検証する手段がわからない
それと別にGenをtypeで名付けた場合と、名付けないで直接同じ内容を書いた場合に振る舞いが違うという問題がある
こちらはまったく謎
いや?これTypeBで実体化されたやつではなく、まだジェネリクスの状態なのか?
いや、それは元のでも同じか
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
は型エラーになりません。
これ違いそう


そしたらなおさら違うじゃん
2回くらいおかしいと指摘したら

>おっしゃる通りです。TypeAとTypeBは互いに代入可能な関係にはありません。以前の説明は誤りでした。正しい説明は以下の通りです。
(略)
> test1
が通るのはおかしいです。正しくは、 test1
も test3
も型エラーが発生するべきです。おそらく、TypeScriptのバージョンや設定に起因するバグや特殊な挙動があるかもしれません。
と説明を撤回した
test3
について
test3
の宣言では、具体的な型シグネチャ <R2 extends TypeB>(x: R2) => 2
を使用しています。このシグネチャと Gen3<TypeA>
のシグネチャを直接比較すると、 TypeB
は TypeA
の部分型ではないため、互換性がないと見なされます。したがって、型エラーが発生します。
まとめ
Gen3<TypeB>
は Gen3<TypeA>
と互換性がありますが、具体的な型シグネチャを直接比較する場合は、互換性がないと判断されます。これが test1
が通り、 test3
が型エラーになる理由です。
そんなことある??

手に終えそうになくなってきた

名前を簡潔にしたver.
この件はbug fixされたようだ
(途中で

さんと認識ずれてそうだと気づいた。早めに聞けばよかったか)
UInt8Arrayとかstringとか全く無関係で、単にgenericsの書き方が適切でないだけ
当初は
Gen2
のように実装したのですが、それでは対処しづらいケースが現れたため
Gen
のような形式に切り替えました

具体的には後日書きます
GenとGen2でどうして挙動が変わるのかについては...
なるほど?

ちかそうなよかん

とりあえず「実装の都合で特殊な処理になってる」という理解をした

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

tsinterface 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
}
うーん

返り値の型にしても関係ない
Genericsを展開すると挙動が変わる
両方展開してもfalse
なにもわからない...
これだな

>In cases where generic arguments haven't been instantiated they are substituted by any before checking compatibility:
tslet 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上での記載など)は調べてないです。分からん

でもたぶん合ってるんでしょう

こんな変換が挟まってしまうのか

たぶんこういうことです

Genを経由すると、この挙動によってanyを経由するのでエラーが起きない
Genを経由しないと、型推論が素直に動き、高階型でインスタンス化されてない型の代入可能性をチェックしてエラーで落ちる
ちなみに、IsSubTypeでtrue or falseにするとエラーの原因が追いづらいので、素直に代入するとよいですよ
これはエラー文のそのままそのとおりで、R2はgenericなtypeでインスタンス化できないからそもそも判定しようがない
あと、ここまで上記は素朴に置換可能な記述で結果が異なることに対する調査ですが、
対して、そもそもじゃあどう書くべきだったか、という視点では、
TSで型レベルの高階関数はむずい
type Gen<T> = <T2 extends T>()=>T2
これは、Tを受けて、T2を受けたら()=>T2を返す型 なので
高階型になってる
そもそも、typeの記述だと無理なやつ
どうしてもやりたいなら、interfaceを駆使しよう
そうです!

同じハックですがこれも分かりやすいです
ありがとうございます!

すでにlike押してた記事だったw
という話があります

ここいらがTypescriptの限界みたいですね……調査ありがとうございます

interfaceとthisで頑張るのも一応TSの限界の内なので頑張ってみるのは面白いかもです、頭こんがらがりそうですが

長いので誰か要約して(他力本願)
