type-challengeの型同士の厳密な等価評価に使われているEqual<X, Y>
type-challengeというTypeScriptの型について学習するためのプロジェクトがある。これは型に関する問題とそのテストケースが提示され、それを満たす実装を書いてコンパイルエラーを無くしていくという流れで進む。
例えばこんな感じの問題。与えられた型がタプルの場合はそのサイズを返すという型を書く問題。type Length<T extends readonly any[]> = T['length']
とでも書けばこれはOK。
/* _____________ Your Code Here _____________ */
type Length<T extends any> = any
/* _____________ Test Cases _____________ */
import { Equal, Expect } from '@type-challenges/utils'
const tesla = ['tesla', 'model 3', 'model X', 'model Y'] as const
const spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] as const
type cases = [
Expect<Equal<Length<typeof tesla>, 4>>,
Expect<Equal<Length<typeof spaceX>, 5>>,
// @ts-expect-error
Length<5>,
// @ts-expect-error
Length<'hello world'>,
]
こういった問題がレベル別でたくさん投稿されているので、step by stepでTypeScriptの型に習熟していくのに最適である。
自分は最近このプロジェクトの問題を解き始めたところなのだが、その中でふと型のテストケースの中で利用されているEqual
という型が気になった。これは標準のユーティリティ型ではなく@type-challenges/utils
で定義されている独自の型だ。この中を覗いてみると実装はこんな感じ。
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false
目的はXとYを型レベルで厳密に等価かどうか比較すること。
実装はConditional Typeを使っているだけだが、最初見た時なぜこれが下記ではないのか疑問だった。これでも十分では?と思ったのだ。
type Equal<X, Y> = X extends Y ? true : false
なぜ<T>() => T extends X ? 1 : 2 extends (<T>() => T extends Y ? 1 : 2) ? true : false
というような書き方をしているのだろうか。
自分の知識では考えても理解ができなかったので少し調べてみると、下記の2つのリンクで大体どういうことか説明されていた。自分なりにこんな感じか?と理解したことをメモしてみる。
まずConditional Type内の<T>() => T
が未確定なので遅延評価になる。
この場合(<T>() => T extends X ? 1 : 2)
が(<T>() => T extends Y ? 1 : 2)
に代入可能であればtrue
、そうでなければfalse
を返すようになるというコードであるが、その代入可能かどうかを決めるのはisTypeIdenticalTo
という内部的な実装である。
isTypeIdenticalTo
はソースコードでこう定義されている。
isTypeIdenticalToの実装。長いので省略。
// TYPE CHECKING
function isTypeIdenticalTo(source, target) {
return isTypeRelatedTo(source, target, identityRelation);
}
function isTypeRelatedTo(source, target, relation) {
if (isFreshLiteralType(source)) {
source = source.regularType;
}
if (isFreshLiteralType(target)) {
target = target.regularType;
}
if (source === target) {
return true;
}
if (relation !== identityRelation) {
if (relation === comparableRelation && !(target.flags & 131072 /* Never */) && isSimpleTypeRelatedTo(target, source, relation) || isSimpleTypeRelatedTo(source, target, relation)) {
return true;
}
}
else {
if (source.flags !== target.flags)
return false;
if (source.flags & 67358815 /* Singleton */)
return true;
}
if (source.flags & 524288 /* Object */ && target.flags & 524288 /* Object */) {
var related = relation.get(getRelationKey(source, target, 0 /* None */, relation));
if (related !== undefined) {
return !!(related & 1 /* Succeeded */);
}
}
if (source.flags & 469499904 /* StructuredOrInstantiable */ || target.flags & 469499904 /* StructuredOrInstantiable */) {
return checkTypeRelatedTo(source, target, relation, /*errorNode*/ undefined);
}
return false;
}
基本的にはsource
とtarget
を===
を用いて厳密な等価評価をするのがメイン。
色々書いたが結局のところ要は下記のy = x
がエラーになるか(代入可能か)どうかに帰着する。
let x: <T>() => (T extends X ? 1 : 2)
let y: <T>() => (T extends Y ? 1 : 2)
y = x
これを内部的に決めるのはisIdenticalTo(X, Y)
であり、つまりX === Y
をしてるのと同値。
? 1 : 2
の部分はどういう意味が???と思っていたがany
やnever
でなければ別に何でも良いっぽかった。難しい。
ということでEqual<X, Y>
はこういう実装になっているらしい。自分が最初にイメージしていた下記の実装だと一部の値型同士等ではうまくいくが、うまくいかないケースも結構ある。
type Equal<X, Y> = X extends Y ? true : false
例えば下記。
Equal<true, boolean>
これは厳密な等価評価をして欲しいのでtrue === boolean
であり、false
を返して欲しい。しかし自分の実装だとtrue extends boolean
になり、これはtrue
を返す。こういう例がいくつかある。
よってこの実装が採用されているということらしい。
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false
ただこの実装はEqual<{x:1}&{y:2}, {x:1,y:2}>
のような交差型では使えなくて、というのもisIdenticalTo
の内部実装のsource.flags !== target.flags
の部分でfalse
になってしまうからとのこと。
TypeScriptの型パズルは難しい。自分でもまだ理解が曖昧な部分も多々あるので間違っていたら指摘してもらえるとありがたい。
Discussion