特定のケースではsatisfies演算子で型が絞り込まれてしまう???
TypeScript 4.9で追加されたsatisfies演算子の予期しない挙動について解説します。リテラル型のユニオンで制約を課された場合に型が絞り込まれる理由と、その仕組みを詳しく説明します。
はじめに
satisfiesはTypeScript 4.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オブジェクトに注目してください(TypeScript Playground)。
type Colors = "red" | "green" | "blue";
const palette1 = {
red: "#ff0000",
green: "#00ff00",
bleu: "#0000ff"
} as const;
const palette2: Record<Colors, string> = {
red: "#ff0000",
green: "#00ff00",
bleu: "#0000ff"
};
const palette3 = {
red: "#ff0000",
green: "#00ff00",
bleu: "#0000ff"
} as const satisfies Record<Colors, string>;
各例ではblueをbleuと誤って記述しています。palette1には型の制約がないため、このミスを検出できません。一方、palette2は型注釈で、palette3はsatisfies演算子で制約を課しているため、タイプミスがエラーとして検出されます。
タイプミスを修正するとそれぞれの型は以下のように推論されます。
// palette1
type Palette1 = {
readonly red: "#ff0000";
readonly green: "#00ff00";
readonly blue: "#0000ff";
};
// palette2
type Palette2 = Record<Colors, string>;
// palette3
type Palette3 = {
readonly red: "#ff0000";
readonly green: "#00ff00";
readonly blue: "#0000ff";
};
palette1とpalette3は同じようにリテラル型が保持されますが、palette2は型注釈通りのRecord<Colors, string>に推論されます。
このようにsatisfies演算子は、式の型を変更せずに制約だけを課します。
対象の式の型が変化するケース
次に以下のuser1とuser2を比較してみましょう(TypeScript 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演算子は「式の型を変更しない」はずなので、user2はuser1と同じ型に推論されると期待されます。しかし、実際には異なる型に推論されます。
// user1
type User1 = {
id: string;
type: string;
name: string;
is_verified: boolean;
};
// user2
type User2 = {
id: string;
type: "teacher";
name: string;
is_verified: true;
};
idやnameは同一ですが、user2の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のエイリアスであり、リテラル型のユニオンとして扱われるためです。
配列の場合でも同じような型推論が行われます(TypeScript Playground)。
type Numbers = (1 | 2 | 3 | 4)[];
const numbers1 = [1, 2, 3];
const numbers2 = [1, 2, 3] satisfies Numbers;
numbers1は単なるnumber型の配列として推論されますが、numbers2はリテラル型のユニオンが制約として設けられているので具体的な数値型の配列まで絞り込まれた状態に推論されます。
// numbers1
type Numbers1 = number[];
// numbers2
type Numbers2 = (1 | 2 | 3)[];
なお、as constを使った場合のようなタプル型[1, 2, 3]にはならない点に注意してください。
似た例
satisfiesの他にも、型の制約によって、リテラル型として推論される例は存在します。
以下のコードを見てください(TypeScript 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は以下のように型が推論されます。
// result
type Result = {
test: string;
};
// result1
type Result1 = {
test: string;
};
// result2
type Result2 = {
test: "a";
};
result2だけ、stringではなく'a'と推論されています。
test2関数は、T extends LiteralTypeの箇所で'a' | 'b'という制約が課されているため型の推論結果が変化してしまいました。
まとめ
リテラル型のユニオンで制約が課されると、リテラル型がそのまま保持されます。satisfiesを使う際はこの挙動を理解しておくと良いでしょう。