特定のケースではsatisfies演算子で型が絞り込まれてしまう???
satisfies演算子
satisfiesはTypeScript4.9で追加された演算子です。4.9のドキュメントでは次のように紹介されています。
The new
satisfiesoperator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.(翻訳 by DeepL)新しい
satisfies演算子を使うと、式の結果の型を変更することなく、式の型がある型にマッチするかどうかを検証することができる。
以下の3つのpaletteオブジェクトを比較してみます(Playgroundで確認する)。各例ではblueをbleuと誤って記述しています。
type Colors = "red" | "green" | "blue";
type RGB = [red: number, green: number, blue: number];
const palette1 = {
red: [255, 0, 0],
green: "#00ff00",
bleu: [0, 0, 255]
} as const;
const palette2: Record<Colors, string | RGB> = {
red: [255, 0, 0],
green: "#00ff00",
bleu: [0, 0, 255]
} as const;
const palette3 = {
red: [255, 0, 0],
green: "#00ff00",
bleu: [0, 0, 255]
} as const satisfies Record<Colors, string | RGB>;
palette1には型の制約がないため、タイプミスがすぐには分かりません。一方でpalette2は型注釈を通じて、palette3はsatisfies演算子を使用して制約を課しています。これにより、タイプミスがエラーとして検出されます。
タイプミスを修正するとそれぞれの型は以下のように推論されます。
declare const palette1: {
readonly red: readonly [255, 0, 0];
readonly green: "#00ff00";
readonly blue: readonly [0, 0, 255];
};
declare const palette2: Record<Colors, string | RGB>;
declare const palette3: {
readonly red: [255, 0, 0];
readonly green: "#00ff00";
readonly blue: [0, 0, 255];
};
palette1とpalette3は同じように推論され、palette2は型注釈通りの型に推論されます。
このようにsatisfies演算子は対象の式の型を変更せずに、式に対する制約だけを課すような働きを持ちます。
対象の式の型が変化するケース
次に以下のuserを比較してみましょう(Playgroundで確認する)。
type User = {
id: string;
type: 'teacher' | 'pupil',
name: string;
is_verified: boolean;
};
const user1 = {
id: 'a',
type: 'teacher',
name: 'a a',
is_verified: true,
};
const user2 = {
id: 'a',
type: 'teacher',
name: 'a a',
is_verified: true,
} satisfies User;
satisfies演算子を使用すると、User型の制約がuser2に適用されますが、推論される型はuser1と同じになると期待されます。しかし、実際には次のように異なる型情報が推論されます。
declare const user1: {
id: string;
type: string;
name: string;
is_verified: boolean;
};
declare const user2: {
id: string;
type: "teacher";
name: string;
is_verified: true;
};
idやnameは同一ですが、typeとis_verifiedだけ'teacher'やtrueのよう型が絞り込まれています。
satisfiesの定義に反しているように見えますが、オブジェクトの型推論とsatisfiesの制約で整合が取れなくなるためこのような挙動となっています。
user1の推論結果から分かるように'teacher'のようなリテラルはそれを拡張したstringのようなプリミティブな型として推論されます。
しかし、そのように推論された場合、satisfies演算子における検証がうまく行えません。
例えば'guardian'と'teacher'の両方ともがstringとして拡張されてしまうと、satisfies演算子で検証を行うとき、string extends 'teacher' | 'pupil'のようになるので常に制約を満たしていないと判断されます。
制約を満たすことを正しく判別するため、リテラル型のユニオンで制約を課された部分はリテラル型が保持されるように推論されます。
先ほどの例で言えば'guardian' extends 'teacher' | 'pupil'や'teacher' extends 'teacher' | 'pupil'のようになるので正しく判別できるようになっています。
is_verifiedの制約がプリミティブ型のbooleanであるにも関わらず、trueがbooleanに拡張されない理由はbooleanがtrue | falseのエイリアスであるため、trueとfalseというリテラル型のユニオンが制約として課されたと判別されるためです。
配列の場合でも同じように型推論されます(Playgroundで確認する)。
type Numbers = (1 | 2 | 3 | 4)[];
const numbers1 = [1, 2, 3];
const numbers2 = [1, 2, 3] satisfies Numbers;
numbers1は単なるnumber型の配列として推論されますが、number2はリテラル型のユニオンが制約として設けられているので(1 | 2 | 3)[]の特定の数値型として推論されています。
declare const numbers1: number[];
declare const numbers2: (1 | 2 | 3)[];
as constを利用した時のような[1, 2, 3]のようなタプル型に推論されていないことに気をつけて下さい。
似た例
型の制約によって、リテラル型として推論される例は他にもあります。
以下のコードを見てください(Playgroundで確認する)。
type PrimitiveType = {
test: string
};
type LiteralType = {
test: 'a' | 'b';
};
const result = {
test: 'a'
};
declare const test1: <T extends PrimitiveType>(arg: T) => T;
declare const test2: <T extends LiteralType>(arg: T) => T;
const result1 = test1({ test: 'a' });
const result2 = test2({ test: 'a' });
result、result1、result2は以下のように型が推論されます。
declare const result: {
test: string;
};
declare const result1: {
test: string;
};
declare const result2: {
test: "a";
};
result2だけ、stringではなく'a'と推論されています。
test2関数は、T extends LiteralTypeの箇所で'a' | 'b'であることを課しているためプリミティブ型に拡張されませんでした。
まとめ
あるリテラルがそのリテラルの対応する型を含むユニオンによって型を文脈的に指定される場合、親プリミティブに拡張されるのではなく実際のリテラル型が保持されます。
satisfiesによって型の制約を課す場合はこのような挙動をすることを知った上で使っていきたいです。
参考
Discussion