DanLevy.net

TypeScriptにおけるゲリラ型定義

反逆的な型デザイン

Hero image for TypeScriptにおけるゲリラ型定義

TypeScriptにおけるゲリラ型定義

この記事では、型デザインを支援する3つの興味深い(そして恐ろしい?)テクニックを探求します!

主な目的は、一貫性があり予測可能なモデル/エンティティ/クラスのインターフェースを実現することです。

型を設計するアプローチ

サードパーティAPIのデータを消費する際など、おそらく「型の実装」に関する様々なパターンを目にしたことがあるでしょう。

注: ここでは、エンティティ関係図(ERD)やオブジェクト指向プログラミング(OOP)の継承階層を構築する「伝統的な」プロセスは意図的に無視します。ここでは、半構造化されたAPIデータを表現するための型を構築しています。

2つの高レベルなアプローチを探ってみましょう:単一の巨大なオブジェクト(トップダウン)対 複数の名前付き型(ボトムアップ)です。

単一の巨大なオブジェクト

再利用性やDRY(Don’t Repeat Yourself)よりも、明示的であることを優先します。

ボーナス: ツールチップにより完全なプレビューが表示されるため、IDE/開発者体験は良好です。

interface ProductDetails {
name: string;
seller: { name: string };
availability: Array<{ warehouseId: string; quantity: number }>;
reviews: Array<{ authorId: number; stars: number }>;
}

明示的な可読性を優先しているため、ある程度の 繰り返し(合理的な範囲内で)は許容されます。プロパティのグループが_何度も_ 繰り返される場合は、遠慮せずに繰り返しフィールドを名前付き型に抽出してください。

複数の名前付き型

再利用性とDRY性を優先します。

このアプローチは、おそらく圧倒的な支持を得ている方法でしょう。

interface ProductDetails {
name: string;
seller: Seller;
reviews: Reviews[];
availability: Availability[];
}
interface Seller { name: string; }
interface Availability { warehouseId: string; quantity: number; }
interface Reviews { authorId: number; stars: number; }

全体的には素晴らしいアプローチですが、欠点がないわけではありません。

⚠️ (およそ)TypeScript v3以降、言語サーバーはツールチップを切り捨て、ネストされたプロパティを省略するようになりました。 💡 状況を少し改善するトリックがあります。Cmd または Ctrl を押したまま、様々な型名にカーソルを合わせてみてください。ツールチップに少なくとも1つ以上のレイヤーのプロパティが表示されるはずです。

なぜこれら2つのアプローチのどちらかを選ばなければならないのでしょうか?(巨大な型 vs 名前付きサブ型。)

テクニック #1: 全部取り

両方を持つことはできないでしょうか?

✅ はい!🎉

export interface ProductDetails {
name: string;
seller: { name: string };
reviews: Array<{ authorId: number; stars: number }>;
availability: Array<{ warehouseId: string; quantity: number }>;
}
export type Seller = ProductDetails["seller"];
export type Review = ProductDetails["reviews"][number];
export type Availability = ProductDetails["availability"][number];
  1. 大きな「プライマリ」構造化型を作成する。
  2. プライマリ型から派生したサブ型をエクスポートする。

このアプローチは、「高レベル」なオブジェクトが1か所でのドキュメント化の恩恵を受けるシステムで特に効果を発揮します。 また、このテクニックはモデル、サービス、クエリ結果など、多くのユースケース間での再利用をサポートします。

テクニック #2: ミックスイン

この戦略は、単一の論理的なオブジェクトを表現するために、正しいフィールド正しい名前で組み合わせることです。目標は、TypeScriptのユーティリティと型のユニオンを使用して、複数のユースケースを効率的に処理することです。

このアプローチは、オブジェクトの層を密結合の分類体系に作成することを目的とする伝統的なOOPの継承や階層とは異なります。ミックスインのアプローチは、平坦で緩く関連した型に関するもので、関連するフィールドをグループ化しながら重複を減らすことを目的としています。

ミックスインの例

interface TodoModel {
text: string;
complete: boolean;
}
interface InstanceMixin {
id: number;
}
/** TodoDraft represents Form state, possibly all undefined */
export type TodoDraft = Partial<TodoModel>;
/** Todo represents a Todo instance record from the database */
export type Todo = TodoModel & InstanceMixin;

Userの例

interface User {
id: number;
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}

データベースへの保存前と保存後のUserを表現してみましょう。

// Core User fields (say for a <form>)
interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// Fields from the database
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
// A User **instance** - with all fields
type UserInstance = InstanceMixin & UserBase;

これで、必要な正確なフィールドを削り出すことができます(例えば、作成/更新にはpasswordが必要ですが、UserInstanceのクエリには含まれません)。

interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
/** User payload for signup, including `password` field */
export type UserPayload = UserBase & { password: string };
/** Represents User type returned from server. */
export type UserInstance = UserBase & InstanceMixin;
  1. 「これは良いプラクティスなのか?」
  2. 「試してみるべきか?」

わかりません。先に進みましょう!

テクニック #3: 名前空間による整理

ここでは、ModelMixins名前空間を宣言します。これにより、いくつかの整理とより明確な再利用パターンが提供されます。

標準化された形状

// `src/types/mixins.d.ts`
namespace ModelMixins {
interface Identity {
id: number;
}
interface Timestamp {
createdAt: Date;
updatedAt: Date;
}
type Instance = ModelMixins.Identity & ModelMixins.Timestamp;
interface HashedPassword {
passwordHash: string;
}
interface InputPassword {
password: string;
}
}

型のユニオンの使用

// `src/types/user.d.ts`
export interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// Single `User` type, using Type Union to dynamically
// represent the pre- & post-creation states.
export type User =
| (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword)
| (UserBase & ModelMixins.InputPassword);

必要に応じて、個別の名前付き型をエクスポートすることもできます。

/** User payload for signup, including `password` field */
export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;
/** Represents User type returned from server. */
export type UserInstance = UserBase & ModelMixins.InputPassword;

実世界での使用例

in演算子を使用してUserInstanceUserPayloadの型を区別するupsert()関数です。

function upsert(user: User) {
if ("id" in user) {
// TypeScript knows `user` here has fields from Instance (id, createdAt, etc)
return updateUser(user.id, user);
} else {
// TypeScript knows this must be the `UserBase & ModelMixins.InputPassword` version of user.
return createUser(user);
}
}

まとめ

3つのテクニックといくつかの関連するサポートアイデアについて説明しました。

おそらくこう尋ねていることでしょう。「これらのパターンは良いのか?これらのアイデアの一部を採用すべきか?」と。

リソース