atama plus techblog
🧷

TypeScriptで知ってコードの安全性が上がったtips集

2024/03/20に公開3

TypeScriptを用いた開発では、その型システムを活かしてランタイムエラーを事前に防いだり、実装漏れを防いだりとコードの安全性の向上を図ることができます。

本記事では、個人的に知ったおかげでコードの安全性が増した!と感じたtipsをまとめました。

※ なお、linterを用いたコードの安全性向上も非常に有効ですが、この記事では主にTypeScriptの型システムに焦点を当てています。

tips集

配列周りのtips

まずは配列を扱う際に役立つ、tipsを紹介します。

配列からUnion型を作成する

↓のように(typeof array)[number]で配列の全要素を持つUnion型を作成できます。

const fruits = ["apple", "banana", "lemon"] as const;
type Fruit = (typeof fruits)[number];
// type Fruit = "apple" | "banana" | "lemon"

↓のように型を先に定義してそれから配列を定義すると、変更を加える際、型と配列の2つを編集しなければならないです。

type Fruit = "apple" | "banana" | "lemon"
const fruits: Fruit[] = ["apple", "banana", "lemon"];

(typeof array)[number]を使えば型と配列の要素を1箇所で定義することができます。
これによって変更を加える際、2箇所編集する必要がなくなり変更漏れの発生を防ぐことができます。

詳しい説明:
https://typescriptbook.jp/tips/generates-type-of-element-from-array

配列がUnion型の全ての要素を網羅しているか確認する

以下のuhyoさんの記事にある便利な関数を使うことで、配列がUnion型の全ての要素を網羅しているか確認することができます。
https://qiita.com/uhyo/items/e9a03aa4ea2db8d1d7fe

この記事で紹介されているallElementsという関数は↓のような定義になっています。

// https://qiita.com/uhyo/items/e9a03aa4ea2db8d1d7fe から引用
type ElementOf<A extends any[]> = A extends (infer Elm)[] ? Elm : unknown;
type IsNever<T> = T[] extends never[] ? true : false;

function allElements<V>(): <Arr extends V[]>(arr: Arr) =>
    IsNever<Exclude<V, ElementOf<Arr>>> extends true ? V[] : {notFound: Exclude<V, ElementOf<Arr>>} {
    return arr => arr as any;
}

このallElementsを使って配列を定義すると配列の要素がUnion型の全要素を持つことを保証できます。

// 適当なUnion型定義
type Fruit = "apple" | "banana" | "lemon";

// OK: Union型の要素全てを網羅している場合
const fruits1: Fruit[] = allElements<Fruit>()(["apple", "banana", "lemon"]);

// エラー: Union型の要素全てを網羅していない場合
const fruits2: Fruit[] = allElements<Fruit>()(["apple", "banana"]); // "lemon"が足りない
// Type '{ notFound: "lemon"; }' is missing the following properties from type 'Fruit[]': length, pop, push, concat, and 26 more.

配列の要素がUnion型の要素を網羅していない場合、型が{ notFound: "lemon"; }のようになり型エラーを出すことができます。

これによって配列の要素が常にUnion型の要素を網羅していることを保証できます!

関数の詳しい仕組みの解説はuhyoさんの元記事をご覧ください。

使えるシーン

関数と呼び出し方を見ても使うシーンがイメージしにくいと思うので、使えるシーンの一例を紹介します。

例えば↓のような食べ物の配列があったとします。

// 食べ物リスト
const foods = ["rice", "apple", "bread", "banana"];

この中からフルーツだけを抜き出した配列を定義するとします。

// 食べ物リストの中のフルーツ
const fruits = ["apple", "banana"];

ここで、大元の食べ物の配列に要素を一つ追加します。

// lemonを追加
- const foods = ["rice", "apple", "bread", "banana"];
+ const foods = ["rice", "apple", "bread", "banana", "lemon"];

追加したlemonはフルーツなのでフルーツを抜き出した配列にも要素を追加する必要があります。

// 食べ物リストにフルーツであるlemonが追加されたので、こちらにもlemonを追加
- const fruits = ["apple", "banana"];
+ const fruits = ["apple", "banana", "lemon"];

が、食べ物の配列とフルーツの配列はそれぞれ独立して定義されているので、どちらかに要素を追加したとき、もう片方の配列への要素の追加を忘れてしまう可能性があります。

そこで、今回紹介したallElements関数を用いれば、追加漏れを防ぐことができます!!

allElementsを用いて↓のようにfruitsを定義してみましょう。

// 1. まずfoodsのUnion型を準備
const foods = ["rice", "apple", "bread", "banana"] as const;
type Food = (typeof foods)[number];
// type Food =  "rice" | "apple" | "bread" | "banana"

// 2. Fruit型を定義
// Fruit型を独立して定義せずにExcludeを使用してFood型から抽出するようにする。
type Fruit = Exclude<Food, "rice" | "bread">
// type Fruit = "apple" | "banana"

// 3. allElementsを使ってfruitsを定義
const fruits: Fruit[] = allElements<Fruit>()(["apple", "banana"]);

ここで、食べ物の配列に"lemon"を追加します。

- const foods = ["rice", "apple", "bread", "banana"] as const;
+ const foods = ["rice", "apple", "bread", "banana", "lemon"] as const;
type Food = (typeof foods)[number];
// type Food =  "rice" | "apple" | "bread" | "banana" | "lemon"

type Fruit = Exclude<Food, "rice" | "bread">;
// type Fruit = "apple" | "banana" | "lemon"

const fruits: Fruit[] = allElements<Fruit>()(["apple", "banana"]);
+ // 型エラーが発生するようになる
+ // Type '{ notFound: "lemon"; }' is missing the following properties from type 'Fruit[]': length, pop, push, concat, and 26 more.

食べ物の配列に"lemon"を追加したことでFruit型にも"lemon"が追加されましたが、フルーツリストには"lemon"が存在しないので型エラーが発生するようになりました。

このように、大元の配列とその要素の一部を抽出した配列が存在する時、
大元の配列を変更したものの、抽出した配列にも変更を反映するのを忘れていた...ということを失くすことができます!!

ts-resetの導入

TypeScriptにはまだ型の扱いが微妙なところがあります。

例えばArray.includesは引数にArrayの要素の型定義を満たしているものしか渡せません。
↓のように型定義以外のものを渡そうとするとエラーになります。

const fruits = ["apple", "banana", "lemon"] as const;
fruits.includes('hoge')
// エラーになる
// Argument of type '"hoge"' is not assignable to parameter of type '"apple" | "banana" | "lemon"'.

そのため、渡したい場合はどこかしらで型キャストをして無理やり渡すしかありません。

// 渡せるようにする例
fruits.includes('hoge' as any);
(fruits as any).includes('hoge');

ただArray.includesを呼ぶために無駄に型キャストが増えるのは嫌です。
↓のようにincludesを呼ぶために元の配列の型を広めたりすると型の効果が減りますし。。

// includesで文字列を渡すために string[] として定義
- const fruits = ["apple", "banana", "lemon"] as const;
+ const fruits: string[] = ["apple", "banana", "lemon"] as const;
fruits.includes('hoge')

なんとかならないかと調べていたらts-resetというライブラリを見つけました。

ts-resetを使うとincludesには何でも渡すことができるようになります!それによって不要な型キャストを削除することができます!
https://www.totaltypescript.com/ts-reset

const fruits = ["apple", "banana", "lemon"] as const;
fruits.includes("hoge")
// エラーにならない!

導入方法

導入は一瞬です。

  1. ts-resetのインストール
npm i -D @total-typescript/ts-reset
  1. reset.d.tsを作成
// Do not add any other lines of code to this file!
import "@total-typescript/ts-reset";

これで完了です!2ステップですぐに導入することができます

メリット

ts-resetの導入によって他にももさまざまなメリットがあります。

JSON.parseunknownを返す

標準のTypeScriptではJSON.parseanyを返します。
そのため実際には存在しないプロパティにアクセスしてもエラーになりません。

const result = JSON.parse("{}") // any

// エラーにならない!!
result.status

ts-resetの導入でJSON.parseunknownを返すようになります。

const result = JSON.parse("{}") // any

// エラー
result.status // 'result' is of type 'unknown'.

unknownなので存在しないプロパティにアクセスするとエラーになります。
プロパティにアクセスするため型validationをする必要が出てきて、型安全になります。

Array.filter(Boolean)で型の絞り込みができる

標準のArray.filterは↓のように型の絞り込みができません。

const array = [1, 2, undefined].filter(Boolean) // (number | undefined)[]

ts-resetの導入によってArray.filter(Boolean)で型の絞り込みができるようになります。

const array = [1, 2, undefined].filter(Boolean) // number[]

その他

その他にもいろいろなメリットがあるので公式ドキュメントを読んでみて下さい!

ルールごとに有効化するかしないかを選択することもできるので、Array.includesの挙動だけ変えたいといった要望も叶えられます!

satisfiesを活かす

TypeScript4.9からsatisfies演算子が追加されました。
satisfiesを使うと型を無駄に広げずに型制約をつけたり、switchでunionの網羅性チェックができたりします。

型のwideningを防ぎつつ、型に制約をつける

satisfiesを使うと型定義を広げるのを防ぎつつ、型制約をつけることができます。

例えば、文字列と数値の組み合わせを持つオブジェクトを定義するとします。

satisfiesを使わないで文字列と数値のRecordという制約をつけるとすると↓のように定義する必要があります。

const fontWeight: Record<string, number> = {
  "normal": 400,
  "bold": 700,
}

こうすると、オブジェクトの型は型注釈でつけたRecord<string, number>になってしまい、存在しないプロパティにもアクセスできてしまいます。

type FontWeight = typeof fontWeight // Record<string, number>

// 存在しないプロパティにアクセスしてもエラーにならない
fontWeight.hoge

as cosnt satisfiesを使うことによって、文字列と数値のRecordという型制約をつけつつ、型定義自体は存在するプロパティのみにすることができます。

const fontWeight = {
  "normal": 400,
  "bold": 700,
} as const satisfies Record<string, number>;
// 型は↓になる
// {
//   readonly normal: 400;
//   readonly bold: 700;
// }

// 存在しないプロパティにアクセスするとエラー
fontWeight.hoge
// Property 'hoge' does not exist on type '{ readonly normal: 400; readonly bold: 700; }'

これによって無駄に型定義を広めることなく、型の制約をつけられるのでコードの安全性を増やすことができます。

詳しい解説は以下の記事が非常に分かりやすかったです。
https://zenn.dev/tonkotsuboy_com/articles/typescript-as-const-satisfies

switch文でunionの網羅性チェック

satisfiesを使うとswitch文(if文も)の条件でunionを網羅しているかをチェックすることができます。

// unionを定義
type Fruit = "apple" | "banana" | "lemon"
const fruit = "apple" as Fruit;

// switch
switch(fruit) {
  case "apple":
  case "banana":
  case "lemon":
    break;
  default:
    // エラーにならない
    throw new Error(fruit satisfies never);
    // fruitの型はneverになるので、`never satisfies never`を満たす
}

switch文の最後のdefaultでUnion satisfies neverを呼ぶと、unionを網羅している場合、UnionTypeの型はneverになるのでnever satisfies neverを満たします。

unionを網羅していない場合、Union satisfies neverを満たさずにエラーが発生します。

switch(fruit) {
  case "apple":
  case "banana":
    break;
  default:
    // エラー
    throw new Error(fruit satisfies never);
    // Type 'string' does not satisfy the expected type 'never'.

これによってunionの網羅性をチェックすることができます。

参考:
https://qiita.com/chelproc/items/c0017cfa268409d2748b

番外編

tipsではないですが、TypeScriptの型システムへの理解を深めることで、型の力を活かした保守性の高いコードを書くことができるようになります。

TypeScriptの型問題集であるtype-challengesは型システムへの理解を増すのにオススメです!
https://github.com/type-challenges/type-challenges

TypeScriptの型システムに関する様々な問題を解くことで、型実装力を高めることがことができます。

自分もtype-challengesを一通りやったおかげで、

  • 型をうまく扱えず無理やり型キャストで解決していたコードが減った
  • 型の実装の共通化ができるようになり重複定義を減らせた

など、少しだけですがコードの安全性を増やせるようなった気がします!

興味ある方はぜひチャレンジしてみてください。
以前type-challengesをやったことによる効果の記事を書いたのでもしご興味あればご覧ください
https://zenn.dev/yutake27/articles/9bba69e63e1399

終わりに

TypeScriptでコードの安全性を高めることができるいくつかのtipsを紹介しました。
TypeScriptの強力な型システムを活用することで、より安全で保守しやすいコードを書くことが可能になります。
気になるものがあれば使ってみてください!

他にもコードの安全性を増すための良い書き方や便利関数などあればぜひ教えて下さい!

atama plus techblog
atama plus techblog

Discussion

misukenmisuken

わかりやすく丁寧なtips集で良い記事でした。

「配列がUnion型の全ての要素を網羅しているか確認する」の部分で代入時に Fruit[] を書かずとも、引数側でエラーを出す方法を記事にしてみたので、もしよろしければ参考にしてみてください。
https://zenn.dev/misuken/articles/f780d23f2f4c49

ましましましまし

すみません、リンクを貼られているuhyoさんの記事も読みましたが初心者ながら疑問に思いましたので質問させていただきます!

// 1. まずfoodsのUnion型を準備
const foods = ["rice", "apple", "bread", "banana"] as const;
type Food = (typeof foods)[number];
// type Food =  "rice" | "apple" | "bread" | "banana"

// 2. Fruit型を定義
// Fruit型を独立して定義せずにExcludeを使用してFood型から抽出するようにする。
type Fruit = Exclude<Food, "rice" | "bread">
// type Fruit = "apple" | "banana"

// 3. allElementsを使ってfruitsを定義
const fruits: Fruit[] = allElements<Fruit>()(["apple", "banana"]);

こちらの例ですが、仮にFoodに"noodle"を追加した場合、

type Fruit = Exclude<Food, "rice" | "bread" | "noodle">

と、除外する部分にも追記をする必要があり、

もう片方の配列への要素の追加を忘れてしまう可能性があります。

と同じ状況になってしまうのではないでしょうか。
Fruitにのみ要素を追加する可能性があり、Fruit以外の要素を追加する可能性はない、という状況に活きるということなのでしょうか?