TSは型推論が楽しい
今までTSをたくさん書く機会がなかったのですが,最近はTSをたくさん書く機会に恵まれたおかげでTSの型推論に面白さを感じています.
TSの型
今まではGoを書く機会が多く,たまにTSを書くと型の弱さが気になっていました.
例えば,Goで次のようなコードはコンパイルエラーとなります.
package main
type A int
type B int
func foo(a A, b B) {}
func main() {
var a A = 1
var b B = 2
foo(b, a) // ./prog.go:12:6: cannot use b (variable of type B) as A value in argument to foo
// ./prog.go:12:9: cannot use a (variable of type A) as B value in argument to foo
}
関数foo
は型A
とB
を引数にとりますが,引数の順番を間違えてしまっているために型エラーとなっています.このコンパイルを通すには,正しい順序で引数を渡すようにするか,明示的に型キャストする必要があります.
- foo(b, a)
+ foo(A(b), B(a))
基本的にこのような型を定義する場合は値を慎重に扱いたい時です.例えばCompanyID
とUserID
を引数にとる関数があった場合に,値を入れ間違えると情報漏洩などのアクシデントにつながる可能性があります.同じint
であっても型でその値の意味を分けることで事故を防げる可能性が上がります.
一方でTSで次のように書いた場合,これはトランスパイルエラーにはなりません.
type A = number;
type B = number;
function foo(a: A, b: B) {}
const a: A = 1;
const b: B = 2;
foo(b, a); // OK, but...
これはtype
構文が型のエイリアスを定義する構文だからです.A
もB
もあくまでnumber
の別名なだけで型がnumber
であることに変わりはありません.
これを防ぐ方法は2つあります.
1つは引数をオブジェクトにする方法です.
type A = number;
type B = number;
function foo(args: {a: A, b: B}) {}
const a: A = 1;
const b: B = 2;
foo({ b, a }); // OK
オブジェクトにすることで,引数の順序を気にする必要がなくなり,順序の間違いは起こりにくくなります.
が,A
もB
もnumber
であることに変わりはないので,あくまで事故が多少起こりにくくなっただけです.
もう一つがブランド型です.
ブランド型
ブランド型はTSのハック記法で,TSの型推論の特性を活かしたものになります.
次のように書くことで,Goと同様の型エラーを得ることができます.
type A = number & { __type: "A" };
type B = number & { __type: "B" };
function foo(a: A, b: B) {}
const a = 1 as A;
const b = 2 as B;
foo(b, a); // Argument of type 'B' is not assignable to parameter of type 'A'.
// Type 'B' is not assignable to type '{ __type: "A"; }'.
// Types of property '__type' are incompatible.
// Type '"B"' is not assignable to type '"A"'.
TSでは,型名にリテラルを使うことができます.
例えば型string
の代わりに"A"
と書くことができます.
-const a: string = "A";
+const a: "A" = "A";
このように書くことで,変数a
は"A"
以外の値を受け付けなくなります.
const a: "A" = "B"; // Type '"B"' is not assignable to type '"A"'.
ブランド型を改めてみてみると型にリテラルが使われていることがわかります.
type A = number & { __type: "A" };
type B = number & { __type: "B" };
const a = 1 as A;
const b: B = a; // Type 'A' is not assignable to type 'B'.
// Type 'A' is not assignable to type '{ __type: "B"; }'.
// Types of property '__type' are incompatible.
// Type '"A"' is not assignable to type '"B"'.
ここで型エラーになっているのは{ __type: "A" }
と{ __type: "B" }
の部分です."A"
と"B"
は相互変換不可能なため,変数a
は型B
を満たせずエラーとなります.
const b: B = a as B; // Conversion of type 'A' to type 'B' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// Type 'A' is not comparable to type '{ __type: "B"; }'.
// Types of property '__type' are incompatible.
// Type '"A"' is not comparable to type '"B"'.
"A"
と"B"
が相互変換不可能なので,当然a
をB
に直接アサーションすることはできません.
一度相互変換可能な型を経由する必要があります.
type A = number & { __type: "A" };
type B = number & { __type: "B" };
type C = number & { __type: string };
const a = 1 as A;
const c: C = a;
const b: B = c as B; // OK
ブランド型の副作用として,number & { __type: "A" }
という型は直接作れないため,必ず型アサーションする必要があります.
TSの型推論
ブランド型でリテラル型の型推論に少し触れましたが,リテラル型を用いた型推論こそTSの魅力だと思っています.
リテラル型
例えばRustのResult<Ok, Err>
型のような型をTSで定義するとします.
// isOk の場合は ok が返り,!isOk の場合は err が返る.
type Result<T, E> = {
isOk: boolean;
ok?: T;
err?: E;
};
const result: Result<string, string> = {
isOk: true,
ok: "ok!",
};
if (result.isOk) {
console.debug(result.ok.toUpperCase()); // 'result.ok' is possibly 'undefined'.
}
Result
型の説明はコメントの通りですが, isOk
の場合にok
の値を返すことは人が実装時に気をつけねばなりません.
また,TSからするとisOk
の場合にok
の値があるかわからないため,型エラーとなります.
- console.debug(result.ok.toUpperCase());
+ console.debug(result.ok?.toUpperCase());
このように書くことで型エラーをなくせますが,リテラル型を使うとコメントの説明で書いた内容を型で表現できるようになります.
// isOk の場合は ok が返り,!isOk の場合は err が返る.
type Result<T, E> = {
isOk: true;
ok: T;
} | {
isOk: false;
err: E;
};
const result: Result<string, string> = {
isOk: true,
ok: "ok!",
};
if (result.isOk) {
console.debug(result.ok.toUpperCase()); // OK
}
{ isOk: boolean; ok?: T; err?: E; }
を{ isOk: true; ok: T; }
と{ isOk: false; err: E; }
に分解することで,TSもisOk
ならok
の値があり,!isOk
ならerr
の値があるとTSに伝えることができます.
このようにTSでは値の組み合わせを型として表現することができ,型を的確に扱うことで実装時の認知負荷を下げることができます.
テンプレートリテラル型
テンプレートリテラル型は実装者に大いなる力をもたらします.
"1900" 〜 "1999" の文字列しか受け付けない型
型Year
を"1900" | "1901" | ... | "1998" | "1999"
と書く代わりに,次のように書くことができます.
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `19${Digit}${Digit}`; // "1900" | "1901" | ... | "1998" | "1999"
const millennium: Year = "2000"; // Type '"2000"' is not assignable to type '"1900" | "1901" | "1902" | "1903" | "1904" | "1905" | "1906" | "1907" | "1908" | "1909" | "1910" | "1911" | "1912" | "1913" | "1914" | "1915" | "1916" | "1917" | "1918" | "1919" | "1920" | ... 78 more ... | "1999"'.
ユニオン型とテンプレートリテラル型を組み合わせることで,"1900" | "1901" | ... | "1998" | "1999"
の値を取り得る型を少ない記述で表現することができます.
Aの値が確定するとBの値も確定する型
「a
の値が"A"
の時,b
の値は"Not A"
になる」ようなデータもテンプレートリテラル型で表現することができます.
// a の値が "A" の時,b の値は "Not A" になる
type Foo = {
a: string,
b: string,
};
次のように書くことでa
の値が"A"
の時にb
の値が"Not A"
ではない場合は型エラーとなります.
// a の値が "A" の時,b の値は "Not A" になる
type Foo<T extends string, U extends `Not ${T}` = `Not ${T}`> = {
a: T,
b: U,
};
const foo: Foo<"A"> = {
a: "A",
b: "B", // Type '"B"' is not assignable to type '"Not A"'.
}
正規化されていないデータ型
正規化されていないデータ型にもテンプレートリテラル型は有用です.
// ドメインに正規化されていないデータ構造がある場合
type Values = {
Foo1: number;
Bar1: number;
Foo2: number;
Bar2: number;
Foo3: number;
Bar3: number;
};
type Foo = number;
type Bar = number;
function normalize(values: Values): [Foo, Bar][] {
return [
[values.Foo1, values.Bar1],
[values.Foo2, values.Bar2],
[values.Foo3, values.Bar3],
];
}
Value
型にあるFoo1
からFoo3
,Bar1
からBar3
の型をTSのRecord
型を用いると次のようにまとめることができます.
type Values = Record<"Foo1" | "Bar1" | "Foo2" | "Bar2" | "Foo3" | "Bar3", number>;
さらにテンプレートリテラル型を用いることでこのように書けます.
type Values = Record<`${"Foo" | "Bar"}${1 | 2 | 3}`, number>;
また,関数normalize
も次のように書き換えることができます.
function normalize(values: Values): [Foo, Bar][] {
const nums = [1, 2, 3] as const; // [1, 2, 3]
return nums.map((n) => [values[`Foo${n}`], values[`Bar${n}`]]); // OK
}
as const
をつけない場合,nums
は[1, 2, 3]
ではなくnumber[]
と推論されるため,型エラーとなります.
function normalize(values: Values): [Foo, Bar][] {
const nums = [1, 2, 3];
return nums.map((n) => [values[`Foo${n}`], values[`Bar${n}`]]); // Element implicitly has an 'any' type because expression of type '`Foo${number}`' can't be used to index type 'Values'.
} // Element implicitly has an 'any' type because expression of type '`Bar${number}`' can't be used to index type 'Values'.
最終的に次のような実装になります.
// ドメインに正規化されていないデータ構造がある場合
type Values = Record<`${"Foo" | "Bar"}${1 | 2 | 3}`, number>;
type Foo = number;
type Bar = number;
function normalize(values: Values): [Foo, Bar][] {
const nums = [1, 2, 3] as const;
return nums.map((n) => [values[`Foo${n}`], values[`Bar${n}`]]);
やりすぎると可読性を損なう可能性はありますが,テンプレートリテラル型をうまく活用することで型推論を利かせつつコードを省力化することができます.
まとめ
TSはデータの振る舞いを型として表現することができます.プリミティブ型自体は貧弱ではあるものの,リテラル型を活用することでTSの型推論は非常に強力な武器になります.
TSは型あるから嬉しいのではなく,型推論が楽しいのだと感じています.
Discussion