フューチャー技術ブログ

ZodでJSONのオブジェクトを実行時に都合の良い型に変換する

いろんなJavaScriptの統計を見ると、今時のウェブフロントエンドの新規開発は80%はTypeScriptになっているということです。また、TypeScript自身を使わなくても、TypeScriptで培われた型推論のパワーで、JavaScriptであってもVSCode上で補完とか思いの外うまくいったりしちゃうので、TypeScriptフレンドリーというのはますます重要になっています。

ですが、TypeScriptが有効なのはコンパイル前とか実装中であり、実行時に流れてくるJSONが果たしてきちんとした型通りの定義なのかはTypeScriptの範疇外です。そこでZodとかのバリデーションを行ってくれるライブラリが使われます。Zodを使えばJSONが規定通りの構造をしているか確認した上で、TypeScriptの型を持った変数に安全に代入してくれます。

ですが、JSONというのはネットワーク上を流したり、ファイルに保存したりには便利ですが、扱えるデータの種類が限られるため、実行中のプログラムからするとパースしてそのまま使うのが決して最適とは言えません。UUIDや日付が扱えなくて文字列になってしまったりします。そのための仕組みがZodにはいくつかあります。

最後の項目のやり方を知りたくて調べ始めたのですが、ついでにシンプルな変換とかロジックを加えて変換というのもついでに整理しておきます。

シンプルな値の型だけの変換

名前を変えずに組み込み型を使って日付(.date())や文字列(.string())、数値(.number())とかに変換するだけなら、いつもの型の間に.coerceを挟むだけでOKです。

const userSchema = z.object({
name: z.string(),
age: z.coerce.bigint(),
birth: z.coerce.date(),
});
type User = z.infer<typeof userSchema>;

const src = {
name: "John",
age: "9007199254740993",
birth: "1980-01-01",
};

const user: User = userSchema.parse(src);
console.log(user.age.toString());
// 9007199254740993
// Number.MAX_SAFE_INTEGERより大きくても大丈夫なので
// 9000兆歳を超えるエルフを登録したくなってもOK
console.log(user.birth.toString());
// Tue Jan 01 1980 09:00:00 GMT+0900 (日本標準時)

ちょっとロジックを加えて変換

UUIDは128ビット(16バイト)のデータを、文字列表記にして扱うことが多いのですが、文字列にすると36文字になります。大量にUUIDがある場合に少しでもサイズを小さくするためにJSON上ではbase64で22文字表記にするが、TypeScriptの場合に文字列表記で扱いたい、みたいなケースです。この場合は次に説明するpreprocess()も使えますがちょっと長くなるので割愛します。

function convertBase64ToUUID(src: string, ctx: z.RefinementCtx) {
let base64String: string;
try {
base64String = atob(src);
} catch (error) {
ctx.addIssue({
message: "invalid base64 string",
code: z.ZodIssueCode.custom,
});
return "";
}
const h = [...base64String].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"));
if (h.length !== 16) {
ctx.addIssue({
message: "invalid length",
code: z.ZodIssueCode.custom,
});
return "";
}
const result = `${h.slice(0, 4).join("")}-${h.slice(4, 6).join("")}-${h.slice(6, 8).join("")}-${h.slice(8, 10).join("")}-${h.slice(10).join("")}`;
return result;
}

const userSchema = z.object({
id: z.string().transform(convertBase64ToUUID),
});

const src = {
id: "QMwh6n0nScyKGCgDc5M74g",
};

const user = userSchema.parse(src);
// 40cc21ea-7d27-49cc-8a18-280373933be2
// いつもの形式の文字列になった!

transform()を使う場合、来る値は文字列だ、というところまではzodが保証した上で変換関数を呼んでくれます。その中身の変換だけに注力すればOKですが、場合によっては変換中にエラーが発生する可能性があります。ここではbase64として不正な文字列が渡ってきた、長さが足りないというケースのエラーハンドリングをしています。

構造を変える

次のような配列がサーバーからは送られてくるが、プログラム中ではidをキーにしたMapで扱いたい、ということがあると思います。

{
items: [
{ id: "1", name: "one" },
{ id: "2", name: "two" },
{ id: "3", name: "three" },
]
}

とりあえずそのまま実装してみたのがこの形です。preprocess()のコールバックの第1引数は未知の値なのでunknownです。unknownから文字列に変換するのは自分で型ガードを実装しても良いですが、ここもZodを使った方がお手軽なので使っています。ここでもポイントはsafeParse()を使い、エラーがあったら先ほどと同じくctxに登録してあげることです。

const originalType = z.array(
z.object({
id: z.string(),
name: z.string(),
}),
);

const containerSchema = z.object({
items: z.preprocess(
(items, ctx) => {
const result = originalType.safeParse(items);
if (result.success) {
const map = new Map<string, string>();
for (const item of result.data) {
map.set(item.id, item.name);
}
return map;
} else {
ctx.addIssue(result.error.errors[0]);
return new Map();
}
},
z.map(z.string(), z.string()),
),
});

const src = {
items: [
{ id: "1", name: "one" },
{ id: "2", name: "two" },
{ id: "3", name: "three" },
],
};

const container = containerSchema.parse(src);
expect(container.items.get("1")).toBe("one");

ジェネリックにしてみる

似たような変換処理がたくさんある場合、1つの変換関数でやりたいですよね?

id属性を持たないオブジェクト型を定義して、それを渡すと、preprocess()が受け取れる変換関数と、第2引数の型定義の両方を作って返す、arrayToMap()関数にしました。先ほどの例は、id以外にnameしか属性がないオブジェクトだったのですが、複数の属性があるケースもあると思うので、結果の型はMap<string, string>ではなく、Map<string, { name: string }>と先ほどとは違う型になるようにしています。

const originalType = z.object({
name: z.string(),
});

function arrayToMap<T extends z.ZodRawShape, S extends z.UnknownKeysParam>(valueType: z.ZodObject<T, S>) {
type Value = z.infer<typeof valueType>;
const arrayType = z.array(z.object({ id: z.string() }).merge(valueType));

return [
// 変換関数
function (items: unknown, ctx: z.RefinementCtx) {
console.log(items);
const result = arrayType.safeParse(items);
console.log(result);
if (result.success) {
const map = new Map<string, Value>();
for (const item of result.data) {
const { id, ...data } = item;
map.set(id, { ...data } as Value);
}
return map;
} else {
ctx.addIssue(result.error.errors[0]);
return new Map();
}
},
// このpreprocessの返り値の型
z.map(z.string(), valueType),
] as const;
}

// 型定義に入れてみる
const containerSchema = z.object({
items: z.preprocess(...arrayToMap(originalType)),
});

// 変換してみる
const src = {
items: [
{ id: "1", name: "one" },
{ id: "2", name: "two" },
{ id: "3", name: "three" },
],
};

const container = containerSchema.parse(src);
expect(container.items.get("1")).toEqual({"name": "one"});

まとめ

Zodでの簡単な値単位の変換はネット上で調べると公式含めてすぐ出てきたのですが、配列のMap変換とエラーハンドリングの仕方がなかったのでやり方を調べるついでにまとめてみました。

エラーがあった場合は変換関数の第2引数のctxにエラー情報を登録するのが肝だな、と思うのですが、最初試した時は無邪気に safe() を使って例外を投げるコードを書いていました。これでも userSchema.parse() では違和感なく使えるのですが、呼び出し元で userSchema.safeParse() 形式で呼び出すと、本来の使われ方とは異なって例外が投げられてしまうので、このようにエラー処理を書く必要がありました。

外部とのインタフェース部分でより安全にデータを扱ったり、プログラム側のつまらない変換処理をオフロードすることで、プログラム側の責務がわかりやすくなったり、Zodを使いこなすとフロントエンドのコードは綺麗になりますね。まあZod関連のコードはその分、ごちゃごちゃになりがちで、臭いものには蓋になってしまうかもしれませんが、そういう割り切りで良いのかな、と思っています。