実例 UnknownifyDiscriminatedUnion / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@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