🎄

実例 UnknownifyDiscriminatedUnion / TypeScript一人カレンダー

2024/12/22に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の19日目です。昨日は『実例 PageContext, AllowTransitionFrom』を紹介しました。

PageContextとsubjectパラメータ

昨日の記事で紹介したPageContext型は、「あるページを表示する際に URLSearchParamsに何が含まれているべきか」を型レベルで定義する仕組みです。

subjectという操作主体となるパラメータをキーとして、ページごとに他の必須パラメータが異なることをDiscriminated unionで表現しています。

しかし、こうした PageContext に紐づくパラメータをNext.js App Routerの標準型で扱おうとすると難点があります。たとえば、Next.jsが用意しているパラメータ関連の型は以下のように弱いものです。

export type PageProps = Readonly<{
  params: Record<string, string>;
  searchParams: Record<string, string | string[] | undefined>;
}>;

この型は非常にゆるい構造となっており、エディタの補完などの恩恵をほとんど受けられません。実際、バリデーション自体はValibotで後から全て検証できるからいいやと思っていると、そのValibotのparse()の引数に渡すオブジェクトを記述する際に型推論がほとんど働かず、バリデーションはできても記述は不便という状況に陥ります。

さらに、Valibotに渡すオブジェクトの推論を正確にしたいという理由でValibotを使うと、「型のためにいちど検証し、検証が終わったらその結果を検証する」というような、明らかに無駄である奇妙なデータフローを生んでしまいます。

そのためランタイムバリデーションと型付けを分け「型と値の検証ではなく、プロパティ漏れの発見を目的とする仕組み」だけを導入したいと考えました。

extractPageContext() 関数で PageContext を作成

以下は、株式会社トレタでのモバイルメニューサービスにおいて、recommendationPage(おすすめ商品を掲載するページ)の固有の型を Valibotで検証する業務上で実際に使われているコードです。掲載許可をいただいています。

// 操作主体 (subject) によらず共通で備えているべきプロパティ群
type Base = Pick<
  Parameters<typeof asPageContext<typeof recommendationPageContext$>>[1],
  "locationId" | "tabId" | "menuId"
>;

export function extractPageContext(
  props: PageProps,
  pos: Pos, // この店舗はどのメーカーのPOS(レジ)を使っているかというメタデータ
): RecommendationPageContext {
  const subject = extractSubject(
    props.searchParams,
    subjectMap[pos.type],
  );

  // 事前に subject を問わず共通なオブジェクトを変数に格納、まだバリデーションは済んでいない
  const ctxBase = {
    locationId: props.searchParams.locationId,
    tabId: props.params.tab_id,
    menuId: props.searchParams.menuId,
  } satisfies Base;

  switch (subject) {
    // プレビューは飲食店スタッフが使う
    case "preview":
      // ここでバリデーション
      return asPageContext(recommendationPageContext$, {
        ...ctxBase,
        subject: "preview",
      });
      
    // 飲食店利用客のテーブルを特定するために sessionId が必要
    case "session":
      return asPageContext(recommendationPageContext$, {
        ...ctxBase,
        subject: "session",
        sessionId: props.searchParams.sessionId,
      });

    // 「割り勘機能」に対応するPOSの場合、個人を特定する identityId も必要
    case "identity":
      return asPageContext(recommendationPageContext$, {
        ...ctxBase,
        subject: "identity",
        sessionId: props.searchParams.sessionId,
        identityId: props.searchParams.identityId,
      });

    default:
      throw new PreconditionError("Invalid subject");
  }
}

このように、const ctxBaseという共通オブジェクトを事前に組み立てたあと、パラメータsubjectが何かによってバリデーション処理に渡すオブジェクトを組み立てるやり方を取っています。asPageContext()はValibot parse()のラッパーです。

ここでtype Baseを宣言することがとても困難でした。どういうことかみていきましょう。

InferInput<T>とunknown

ValibotでBranded typesを多用していると、InferOutput<T>InferInput<T>の違いが重要になります。

たとえば、InferInput<T>ならばBrandedされる前の素の型(FilledStringならstring型)を得られます。しかし、URLSearchParamsの型にはundefinedの可能性があるため、単純にInferInput<T>を適用してもundefined checkを別途実施する必要があるという不都合が発生します。

ここで「あとでValibotをまとめて通すから今だけundefinedを許容したい」としつつも「キーの漏れは厳密に検査したい」という要求があるとき、結局 unknownにせざるを得ない状況に陥ります。

ところが、次の懸念として全てのプロパティを一様にunknownにすると、今度はDiscriminated unionの識別に必須なsubjectプロパティすらもunknownになってしまい、構造を維持できなくなります。

ここで、「subjectだけは確定させて、その他のプロパティだけを全てunknownにする」という型操作をやりたくなります。

subject 以外をすべて unknown に

次のユーティリティ型 UnknownifyDiscriminatedUnionは、先述のような問題を解決するために筆者が実装したものです。

export const subjectKey = "subject";

type UnknownifyDiscriminatedUnionPerSubject<
  SUBJECT extends "preview" | "session" | "identity",
  T,
> = T extends Extract<T, { [subjectKey]: SUBJECT }>
  ? Record<
      Exclude<keyof Extract<T, { [subjectKey]: SUBJECT }>, typeof subjectKey>,
      unknown
    > & { [subjectKey]: SUBJECT }
  : never;

export type UnknownifyDiscriminatedUnion<T> =
  | UnknownifyDiscriminatedUnionPerSubject<"preview", T>
  | UnknownifyDiscriminatedUnionPerSubject<"session", T>
  | UnknownifyDiscriminatedUnionPerSubject<"identity", T>;

Conditional Types などを活用して、Discriminated Union の「識別子となるプロパティ」だけは維持し、その他のプロパティを一括でunknownに変換します。 結果として「subject の値による Discriminated Union は維持しつつ、それ以外の項目はバリデーション前の仮状態として扱う」という型の操作を実現します。

UnknownifyDiscriminatedUnion型が整うと、その型を使ってこの場面専用のparse()関数を実装します。一見すると複雑ですが「subjectごとの曖昧な型」を「subjectごとの厳格な型」に確定させるためのバリデーション関数であるとしてひとつひとつ読み解いていくことができます。

import { type InferInput, type InferOutput, parse } from "valibot";
import type { UnknownifyDiscriminatedUnion } from "./unknownify-discriminated-union";

export function asPageContext<
  S extends Parameters<typeof parse>[0],
  V extends UnknownifyDiscriminatedUnion<
    InferInput<S>
  > = UnknownifyDiscriminatedUnion<InferInput<S>>,
>(s: S, v: V): Extract<InferOutput<S>, { subject: V["subject"] }> {
  return parse(s, v) as Extract<InferOutput<S>, { subject: V["subject"] }>;
}

先述のtype Baseはこの仕組みを使って部分的に宣言することを可能としました。その内容を再掲します。

type Base = Pick<
  Parameters<typeof asPageContext<typeof recommendationPageContext$>>[1],
  "locationId" | "tabId" | "menuId"
>;

型テストの実例

この型が期待通りに実装されているか、Vitestの型テストを実施しておきましょう。expectTypeOf() を使いsubject情報は維持され、それ以外のプロパティがunknownになっていることを検証します。

import { describe, expectTypeOf, test } from "vitest";

import type { UnknownifyDiscriminatedUnion } from "./unknownify-discriminated-union";
import type { subjectKey } from "./subject-key";

describe("UnknownifyDiscriminatedUnion", () => {
  describe("subject 以外のプロパティはすべて unknown 型として返る", () => {
    test("a のみ", () => {
      type Expected = Record<"a", unknown> & {
        [subjectKey]: "preview";
      };

      type Actual = UnknownifyDiscriminatedUnion<{
        [subjectKey]: "preview";
        a: string;
      }>;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });

    test("a, b", () => {
      type Expected = Record<"a" | "b", unknown> & {
        [subjectKey]: "preview";
      };

      type Actual = UnknownifyDiscriminatedUnion<{
        [subjectKey]: "preview";
        a: string;
        b: number;
      }>;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });
  });

  describe("複数の subject があるとき、その subject 情報は維持して、それ以外のプロパティはすべて unknown 型として返る", () => {
    test("preview, session", () => {
      type Expected =
        | (Record<"a", unknown> & {
            [subjectKey]: "preview";
          })
        | (Record<"a" | "b", unknown> & {
            [subjectKey]: "session";
          });

      type Actual = UnknownifyDiscriminatedUnion<
        | {
            [subjectKey]: "preview";
            a: string;
          }
        | {
            [subjectKey]: "session";
            a: string;
            b: number;
          }
      >;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });

    test("preview, session, identity", () => {
      type Expected =
        | (Record<"a", unknown> & {
            [subjectKey]: "preview";
          })
        | (Record<"a" | "b", unknown> & {
            [subjectKey]: "session";
          })
        | (Record<"a" | "b" | "c", unknown> & {
            [subjectKey]: "identity";
          });

      type Actual = UnknownifyDiscriminatedUnion<
        | {
            [subjectKey]: "preview";
            a: string;
          }
        | {
            [subjectKey]: "session";
            a: string;
            b: number;
          }
        | {
            [subjectKey]: "identity";
            a: string;
            b: number;
            c: boolean;
          }
      >;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });
  });
});

このように、複数の subject 値に対応する大きな Discriminated Union 型があったとしても、UnknownifyDiscriminatedUnion を通せば「subject はそのまま保持」して「他プロパティだけunknown」という形にできるため、バリデーション前であっても型推論を一部維持しながら型推論を諦める必要がなくなります。

any回避とunknownの採用

型名としては "Unknownify" はカジュアルな表現ですが、筆者がChatGPTに相談したところ思いついてくれた言葉がしっくりきたため、そのまま拝借しました。

unknownはTypeScript 3.0で追加された型です。筆者は「どんな場面でもany型だけは使いたくない」という強い意志を持っており、今回のように型付けに迷う場面でもany型を使わずとも、こうしたユーティリティ型を工夫すればプロパティ一致や構造一致を担保しながら柔軟さを得るようにしています。

こうしたユーティリティ型のちょっとした工夫は、特にDiscriminated unionを多用するにした大規模な TypeScript アプリケーションの開発で活きてきます。

明日は『ECMAScript Private Fields』

本日は『実例 UnknownifyDiscriminatedUnion』を紹介しました。明日は『ECMAScript Private Fields』を紹介します。それではまた。

Discussion