💨

TSは型推論が楽しい

2024/04/03に公開

今まで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
}

The Go Playground

関数fooは型ABを引数にとりますが,引数の順番を間違えてしまっているために型エラーとなっています.このコンパイルを通すには,正しい順序で引数を渡すようにするか,明示的に型キャストする必要があります.

-   foo(b, a)
+   foo(A(b), B(a))

基本的にこのような型を定義する場合は値を慎重に扱いたい時です.例えばCompanyIDUserIDを引数にとる関数があった場合に,値を入れ間違えると情報漏洩などのアクシデントにつながる可能性があります.同じ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...

TypeScript Playground

これはtype構文が型のエイリアスを定義する構文だからです.ABもあくまで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

TypeScript Playground

オブジェクトにすることで,引数の順序を気にする必要がなくなり,順序の間違いは起こりにくくなります.
が,ABnumberであることに変わりはないので,あくまで事故が多少起こりにくくなっただけです.

もう一つがブランド型です.

ブランド型

ブランド型は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"'.

TypeScript Playground

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"'.

TypeScript Playground

ここで型エラーになっているのは{ __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"が相互変換不可能なので,当然aBに直接アサーションすることはできません.
一度相互変換可能な型を経由する必要があります.

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

TypeScript Playground

ブランド型の副作用として,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'.
}

TypeScript Playground

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
}

TypeScript Playground

{ 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"'.

TypeScript Playground

ユニオン型とテンプレートリテラル型を組み合わせることで,"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"'.
}

TypeScript Playground

正規化されていないデータ型

正規化されていないデータ型にもテンプレートリテラル型は有用です.

// ドメインに正規化されていないデータ構造がある場合
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],
  ];
}

TypeScript Playground

Value型にあるFoo1からFoo3Bar1からBar3の型をTSのRecord型を用いると次のようにまとめることができます.

type Values = Record<"Foo1" | "Bar1" | "Foo2" | "Bar2" | "Foo3" | "Bar3", number>;

さらにテンプレートリテラル型を用いることでこのように書けます.

type Values = Record<`${"Foo" | "Bar"}${1 | 2 | 3}`, number>;

TypeScript Playground

また,関数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}`]]);

TypeScript Playground

やりすぎると可読性を損なう可能性はありますが,テンプレートリテラル型をうまく活用することで型推論を利かせつつコードを省力化することができます.

まとめ

TSはデータの振る舞いを型として表現することができます.プリミティブ型自体は貧弱ではあるものの,リテラル型を活用することでTSの型推論は非常に強力な武器になります.

TSは型あるから嬉しいのではなく,型推論が楽しいのだと感じています.

株式会社モニクル

Discussion