特定のケースではsatisfies演算子で型が絞り込まれてしまう???
satisfies演算子
satisfies
はTypeScript4.9で追加された演算子です。4.9のドキュメントでは次のように紹介されています。
The new
satisfies
operator 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