虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

TypeScriptの型実践

こんにちは、虎の穴ラボのはっとりです。

本記事は2023 夏のブログ連載企画10日目の記事になります。

7月6日は、鷺山さんの「KotlinのDIフレームワーク『Koin』でお手軽DI」でした。
7月10日は、大場さんの記事が公開予定です。ご期待ください!

今回の記事では、私の推しプログラミング言語: TypeScriptの組み込み型とその応用を紹介します。

前置き

以下のような商品の型を定義するとします。

interface Product {
  id: number;
  name: string;
  price: number;
  createdAt: string;
}

この型を使って商品を登録しようと思います。

function registerProduct(production: Product): Product {
  // ... 商品登録処理
}

const product = registerProduct({
  // Argument of type '{ name: string; price: number; }' is not assignable to parameter of type 'Product'.
  //  Type '{ name: string; price: number; }' is missing the following properties from type 'Product': id, createdAt
  name: '虎の穴ラボの薄い本。vol.7',
  price: 0,
});

登録するまでidやcreatedAtの項目は決まっていないですが、これらの項目がないと型エラーが発生します。
適当な値を入れてしまえばよいのですが、利用しない値を設定するのはあまりスマートなやり方ではありません。
そこでidやcreatedAtの設定は必須でなくせば上記のコードは通ります。
※TypeScriptではObjectのプロパティに?をつけるとそのプロパティが未設定でもよくなります。

interface Product {
  id?: number;
  name: string;
  price: number;
  createdAt?: string;
}

ただし、これだと少し問題があります。以下のコードを見てみましょう

function findProduct(id: number): Product | null {
  // ... 商品取得処理
}

function reviewProduct(productId: number, comment: string) {
  // ... 商品レビューを書く
}

const product = findProduct(1);

if (product) {
  // Argument of type 'number | undefined' is not assignable to parameter of type 'number'.
  // Type 'undefined' is not assignable to type 'number'.
  reviewProduct(product.id, '良い本だと思います!');
}

reviewProduct 関数は第1引数にnumber型を求めていますが、 product.id の型は number | undefinedであるため受け付けることができません。

idが必須にならないのは登録前だけであるため、このような場合は、登録済みの商品と登録前の商品で型を分けたほうが良さそうです。

interface Product {
  id: number;
  name: string;
  price: number;
  createdAt: string;
}

interface NewProduct {
  name: string;
  price: number;
}

function registerProduct(production: NewProduct): Product {
  // ... 商品登録処理
}

function findProduct(id: number): Product | null {
  // ... 商品取得処理
}

function reviewProduct(productId: number, comment: string) {
  // ... 商品レビューを書く
}

const product = registerProduct({
  name: '虎の穴ラボの薄い本。vol.7',
  price: 0,
});

const product = findProduct(1);

if (product) {
  reviewProduct(product.id, '良い本だと思います!');
}

これで型エラーはなくなりました。

組み込み型: Omit

Product型とNewProduct型はidとcreatedAt以外は同じプロパティを持ちます。
Product型に追加のプロパティを追加したい場合、Product型とNewProduct型の両方に追加する必要があります。

interface Product {
  id: number;
  name: string;
  price: number;
+  description: string;
  createdAt: string;
}

interface NewProduct {
  name: string;
  price: number;
+  description: string;
}

プロパティが増える度に2つの型を修正する必要があります。面倒なのもそうですが、片方の修正忘れなども発生してしまいがちです。

前置きが長くなりましたが、ここから組み込み型の出番です。

Product型とNewProduct型の違いはidとcreatedAtの有無だけとわかっているなら組み込み型のOmitを使って以下のように定義できます。

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  createdAt: string;
}

type NewProduct = Omit<Product, 'id' | 'createdAt'>;
// 以下と同等になる
// interface NewProduct {
//   name: string;
//   price: number;
//   description: string;
// }

これでProduct型を修正すればNewProduct型も追従して修正されるようになりました。

組み込み型: Readonly

TypeScript(というかJavaScript)のconstは再代入を禁止するだけなので、配列やオブジェクトプロパティは変更可能になっています。
そのため、以下のように書き換えが可能になっています。

const product: Product = {
  id: 1,
  name: '虎の穴ラボの薄い本。vol.7',
  price: 0,
  description: '⻁の穴ラボのメンバーが制作する技術同人誌です。',
  createdAt: '2023-05-20T00:00:00+09:00',
};

product.name = '虎の穴ラボの薄い本。vol.8';

書き込みを禁止させるのであれば、組み込み型: Readonlyを使うことでプロパティへの再代入を禁止できます。

const product: Readonly<Product> = {
  id: 1,
  name: '虎の穴ラボの薄い本。vol.7',
  price: 0,
  description: '⻁の穴ラボのメンバーが制作する技術同人誌です。',
  createdAt: '2023-05-20T00:00:00+09:00',
};

// Cannot assign to 'name' because it is a read-only property.
product.name = '虎の穴ラボの薄い本。vol.8';

ただし、Readonlyは深い階層のプロパティまで再代入を禁止することができません。

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  maker: {
    id: number;
    name: string;
  };
  createdAt: string;
}

const product: Readonly<Product> = {
  id: 1,
  name: '虎の穴ラボの薄い本。vol.7',
  price: 0,
  description: '⻁の穴ラボのメンバーが制作する技術同人誌です。',
  maker: {
    id: 100,
    name: '虎の穴ラボ',
  },
  createdAt: '2023-05-20T00:00:00+09:00',
};

// Cannot assign to 'name' because it is a read-only property.
product.name = '虎の穴ラボの薄い本。vol.8';
// こちらは代入可能・・・
readOnlyProduct.maker.name = 'ユメノソラ';

これを解決するために以下のような型を定義します。

type ReadonlyDeep<T extends object> = {
  readonly [K in keyof T]: ReadonlyDeep<T[K]>;
};

これを利用すると深い階層のプロパティまで再代入を禁止することができます。

const product: ReadonlyDeep<Product> = {
  // ...(省略)
};

// Cannot assign to 'name' because it is a read-only property.
product.name = '虎の穴ラボの薄い本。vol.8';
// Cannot assign to 'name' because it is a read-only property.
readOnlyProduct.maker.name = 'ユメノソラ';

ReadonlyDeep型はtype-festを参考に作りました。
先程紹介したReadonlyDeep型はobject型にしか使えませんが、type-festのReadonlyDeep型はプリミティブ型やMap,Setにも対応しています。

type-festには他にも便利な型がたくさんありますので、READMEを確認してみてください。

まとめ

型をきちんと定義することでランタイムエラーを極力減らすことが可能です。
TypeScriptの型はかなり柔軟に定義できるので、個人的にかなり気に入っています。

組み込み型だけでもかなり様々な状況に対応できますが、思ったとおりの型が作れない場合はtype-festのようなライブラリを探すのも良いのではないでしょうか。

採用情報

虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
カジュアル面談やエンジニア向けイベントも随時開催中です。ぜひチェックしてみてください♪
yumenosora.co.jp