エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

型を少し工夫して、より安全なコードへ

こんにちは、デジスマチームでエンジニアをやっている堀田です。

これまで、TypeScriptの型で色々試したことがあります。

遊ぶことの方が多かったですが、先日M3 TechTalkで実用的かも?と思える話をしました。 そこでは、3つの場面を想定して、それぞれの場面でより安全なコードを書くための型定義を提案しました。

この記事では、その時の話をまとめて紹介します。

1. key?

早速、1つ目の場面です。 ここでは、「パラメータを受け取って、その値によって生成か更新か挙動が変わる」以下のような関数を扱うことを考えます。

function createOrUpdate(params: { id?: string, name: string }) {
  if (params.id === undefined) {
    create(params.id);
  } else {
    update(params.id, params.name);
  }
}

UIを実装して、buttonが押される場合を考えると

const onClickCreate = () => {
  createOrUpdate({
    name: "Digisma",
  });
}

const onClickUpdate = (id: string) => {
  createOrUpdate({
    id,
    name: "Digisma2",
  });
}

このような使われ方がされそうです。 実際、これらは正常に動きます。

次に、以下のような実装をしてしまったと仮定します。

const onClickUpdate = (id: string) => {
  createOrUpdate({
    name: "Digisma2",
  });
}

idを createOrUpdate に渡し忘れているので、Updateを実行したい場面ですが、Createの処理が実行されてしまいます。

function createOrUpdate(params: { id?: string, name: string })

元の関数定義を見てみましょう。params の型定義が { id?: string, name: string } となっています。 idの後ろに ? がありますね。

TSではkeyの後ろの ? は省略可能であることを意味します。ただ単にundefinedを受け付けたいだけならば、 { id: string | undefined, name: string } のように、明示的にUnion型で書いた方が安全かもしれません。

const onClickCreate = () => {
  createOrUpdate({
    id: undefined,
    name: "Digisma",
  });
}

const onClickUpdate = (id: string) => {
  createOrUpdate({
    id,
    name: "Digisma2",
  });
}

Createの方でも id: undefined を渡す必要が出てきますが、「idが無いので、Createする」という風に読めるので全く問題無さそうです。

undefinedを受け付けたいだけなのか、省略可能にしたいのか区別して書くと良さそうですね。

2. ログ送信

2つ目は、ログを送信する場面を考えます。

サービスを開発していると、「あるページが表示された時は page_view ログを、 このボタンが押された時は click ログを送信したい」のような状況に遭遇すると思います。

ここではシンプルに、以下のようなログを送信したくなったとしましょう。

EventName Payload
page_view { id: string }
click -

page_view ログを送るときはidが追加で必要で、click ログを送る時は追加のデータは何も必要無いと言う場面です。(サービス開発が進むにつれて、ログの種類を増やす可能性はありますが、現時点ではこの2種類だけとします。)

ログ送信の実装は複数箇所に散らせたく無いので、以下のように共通のログ送信関数を用意することにします。

function sendLog0(event:  string, payload?: Record<string, string>) {
  const data = {
    eventName: event,
    ...payload,
  };

  console.log(data); // 実際には、サーバーにリクエストを送る想定
}

次のような呼び出しで、ログを送信できます。

ただし、最初の2つは正常ですが、残りの3つは異常系です。

sendLog0("page_view", { id: "123" });
sendLog0("click");

sendLog0("page_view"); // 1
sendLog0("click", undefined); // 2
sendLog0("click", { id: "123" }); // 3

これらは、それぞれ別々の理由で異常系と分類しました。

  1. page_viewは、送信時にidを付与する必要があるがそれが抜けているので、不具合
  2. clickは、送信時に何も送る必要が無いため、冗長
  3. clickは、送信時に何も送る必要が無いが、idが送られてしまっている。 (clickをpage_viewと勘違いしている可能性も否定できず)

あり得ないパターンは、ビルド時に弾けると嬉しいモチベーションがあるので、型を強化してみましょう。

まず、送信する可能性のあるEventName と、それに対応するEventPayloadの型定義をします。

type EventName = "page_view" | "click";
type EventPayload = {
  page_view: { id: string };
  click: undefined;
};

今後のログ仕様の拡張も想定して、新しい型を定義しています。

次に、これらの型を使ったバージョンのログ送信関数を実装します。

function sendLog<E extends EventName>(
  event: E,
  ...payloads: EventPayload[E] extends undefined ? [] : [EventPayload[E]],
) {
  const data = {
    eventName: event,
    ...payloads[0],
  };

  console.log(data);
}

...payloads の部分は、 後述する Rest parameters with tuple types と呼ばれるテクニックです。

この関数を使うと、上の呼び出しパターンは以下のようになります。

sendLog("page_view", { id: "123" });
sendLog("click");

// Build Error
// sendLog("page_view"); // 1
// sendLog("click", undefined); // 2
// sendLog("click", { id: "123" }); // 3

想定していないケースでは、ビルドエラーにできました。 この時、内部の実装にはほとんど変化が無いことが分かります。

同じロジックでも、型の付け方を変えるだけで安全に書けるようになった例の1つでした。

Rest parameters with tuple types

このテクニックを紹介します。まず、TSにおけるTuple型を知る必要あります。Tuple型はArray型を少し厳格にした型で、以下のような性質があります。

  • ビルド時に長さが決定する
  • ビルド時に、どの位置に何の型が存在するか決定する

例えば、次のようにArgs型を定義した場合

type Args = [number, string, boolean];
  • Args型の長さは 3
  • Args[0]はnumber型、Args[1]はstring型、Args[2]はboolean型

のような性質がビルド時に決定します。

Rest parameters with tuple types は、Tuple型をrest parametersの型として扱うテクニックです。

以下の引用 のように、

When a rest parameter has a tuple type, the tuple type is expanded into a sequence of discrete parameters. For example the following two declarations are equivalent:

「Tuple型を関数のrest parametersの型として扱った場合、そのTuple型の各要素を順番に定義した場合と同等とする」という仕様です。

例えば、次のfoo関数を定義した場合、これらはどちらも同じということです。

declare function foo(...args: [number, string, boolean]): void;
declare function foo(args_0: number, args_1: string, args_2: boolean): void;

先ほど実装した sendLog 関数では、...payloads に対して以下のような型定義をしました。

  ...payloads: EventPayload[E] extends undefined ? [] : [EventPayload[E]]

これは、「あるEvent、Eに対応するEventPayloadがundefinedであれば空のTuple型を、何か定義されていればそれ自身だけを持つ長さ1のTuple型」を示しています。

これを ... で展開することで、「EventPayloadが必要無い時は余計な引数を受け付けず、何か定義されていればそれだけを必ず渡す」ことが実現できました。

Function Overloadsでも実現可能

今回のケースであれば、overloadでも実現できます

TSのoverloadは以下のように、いくつかの関数定義を重ねて書いていき、一番下の定義に実装を書くという形式で実装します。

function sendLog2<E extends EventName>(
  event: EventPayload[E] extends undefined ? never : E,
  payload: EventPayload[E],
): void;
function sendLog2<E extends EventName>(
  event: EventPayload[E] extends undefined ? E : never,
): void;
function sendLog2<E extends EventName>(event: E, payload?: EventPayload[E]) {
  const data = {
    eventName: event,
    ...payload,
  };

  console.log(data);
}

この時、一番下の実装が書かれた関数定義を Implementation Signature、それ以外の重ねて書いた部分を Overload Signature と呼びます。 重要な点としては、使う側からは Overload Signature の部分しか見えないということです。

今回のケースで sendLog2 を呼び出す場合は

function sendLog2<E extends EventName>(
  event: EventPayload[E] extends undefined ? never : E,
  payload: EventPayload[E],
): void;

function sendLog2<E extends EventName>(
  event: EventPayload[E] extends undefined ? E : never,
): void;

この2つの内、どちらかの形式で呼び出す必要があります。

例えば以下のように、1つだけの引数で呼び出した場合、2番目のSignatureで呼ばれたと解釈されます。このSignatureではEventPayloadが定義されている場合の第一引数の型は event: never です。

never型にstring型を渡そうとしているのでエラーになります。

sendLog2("page_view");

次は以下のように、2つの引数で呼び出してみます。この場合は、1番目のSignatureで呼ばれたと解釈されます。このSignatureでは、EventPayloadがundefinedの時に第一引数の型が event: never となるので、上と同じ理屈でエラーになります。

sendLog2("click", undefined);
sendLog2("click", { id: "123" });

最終的に、EventPayloadが存在する場合は必ず2つの引数を、存在しない場合は1つの引数だけを渡す制約がかかることになります。

3. 画面遷移パス生成

最後は、画面遷移パスを生成するケースを考えます。

例えば、Next.js や React Routerなどを使っていると、

router.push("/users/123");

みたいに、画面遷移のためのパスを生成したいことがあると思います。シンプルにやるならば、

`/users/${userId}`

このように直接埋め込む形式を取れますが、他の案を考えてみましょう。

function createPath0(path: string, param?: Record<string, string>): string {
  let p: string = path;
  for (const key in param) {
    const value = param[key];
    p = p.replace(`:${key}`, value);
  }

  return p;
}

例えば、こういう「特定の文字列をreplaceして生成する」方式も考えられます。 いくつかのパターンで、この関数を呼び出してみます。

// output: "/users/user1"
createPath0("/users/:userId", { userId: "user1" });

// output: "/users/:userId"
createPath0("/users/:userId");
createPath0("/users/:userId", { userid: "user1" });

最初の呼び出しは、正常に処理されています。ただ、2番目、3番目の呼び出しでは渡したパスのフォーマットがそのまま返されてしまいます。

  • 2番目の呼び出し: replaceされるべき文字列 ":userId" が存在するが、それに対応するパラメータを渡し忘れている
  • 3番目の呼び出し: replaceされるべき文字列 ":userId" が存在し、パラメータも渡しているが "userid" にタイポしている

想定していないパターンは、ビルド時に検知できると嬉しいので型付けをしていきます。

まず、可能性のあるパスのフォーマット型、Path型を定義します。

type Path = "/users" | "/users/:userId" | "/users/:userId/items/:itemId";

次に、渡されたパスのフォーマット型からreplaceすべき文字列を抽出する型、PathParams型を定義します。

type PathParams<PATH extends string> = PATH extends `${string}:${infer Param}/${infer Rest}` 
  ? Param | PathParams<`/${Rest}`>
  : PATH extends `${string}:${infer Param}`
    ? Param 
    : never;

この型は、渡された型パラメータが

  • :A/B の形式である場合、Aを抽出し、Bを再帰的に処理する
  • :Aの形式である場合、Aを抽出する
  • それ以外の場合、neverを返す

というルールで処理し、最終的に抽出したパラメータのUnion型 ( P1 | P2 | never )を返す型です。

先ほど定義したPathを渡してみると、次のような結果になります。replaceすべき文字列のUnion型が得られたことが分かります。

// never
type Example1 = PathParams<"/users">;
// "userId"
type Example2 = PathParams<"/users/:userId">;
// "userId" | "itemId"
type Example3 = PathParams<"/users/:userId/items/:itemId">;

次に、これらの型を使ってcreatePathを実装します。 *1

function createPath<PATH extends Path>(
    path: PATH,
    ...params: [PathParams<PATH>] extends [never] ? [] : [{ [key in PathParams<PATH>]: string }]
): string {
    let p: string = path;
    let param = params[0] ?? ({} as { [key: string]: string });

    for (const key in param) {
        const value = param[key];
        p = p.replace(`:${key}`, value);
    }

    return p;
}

何パターンかで呼び出してみます。

// "/users" 
createPath("/users");
// "/users/user1"
createPath("/users/:userId", { userId: "user1" });
// "/users/user1/items/1"
createPath("/users/:userId/items/:itemId", { userId: "user1", itemId: "1" });

// Build Error
// createPath("/users", { userId: "user0" }); // 4
// createPath("/users/:userId/items/:itemId", { userId: "user0" }); // 5

最初の3つの呼び出しでは正常にパスを取得できています。また、このコードを書く時に補完が効くようになるというありがたい副産物も得られました。 4番目と5番目の呼び出しはビルド時にエラーになります。

  • 4番目: replaceすべき文字列が無いのに、パラメータを渡しているのでエラー
  • 5番目: replaceすべき文字列は、"userId"と"itemId"の2つ。"userId"の方は渡しているが、"itemId"を渡していないのでエラー

まとめ

3つの場面を仮定して、普通の型で実装したパターンと強化版の型で実装したパターンを比べました。Before/Afterで比べてみると、内部のロジックはほとんど同じであることが分かります。

内部ロジックの書き換えはほとんどしていないですが、型の付け方を工夫するだけで呼び出し側の振る舞いを大きく改善できました。

やり過ぎない程度の型付けで、安全なコードを書けるようになるのがベストですね。

We are hiring!!

エムスリーでは絶賛エンジニアを募集中です! 今回紹介した技術スタック・アーキテクチャ以外にも様々な構成のプロダクトがありますので、ご興味ある方は是非こちらからお願いします!

jobs.m3.com

*1: exntends [Never]、Never型の判定について