🧑‍🔧

なんで TypeScript に Schema Validation ライブラリが必要なのか(Valibot作例付き)

2023/12/27に公開

TypeScript で型情報がないデータに型情報を付与したい場面があります。その時に Schema Validation ライブラリが役立ちます。

例えばよくある Fetch API を使って JSON 形式のレスポンスを取得する関数を考えてみます。

// あえて間違えています
type Post = {
  postId: number; // 本当は id: number
  userId: number;
  title: string;
  body: string;
};

const fetchPost = async (id: number): Promise<Post> => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );
  // 本当はany型
  const json = await response.json();
  return json;
};

const result = await fetchPost(1);

console.log(result.postId); // undefined
console.log(result.id); // 型エラーだが1を返す

await response.json()の返り値の型はanyなのですが、関数自体の返り値はPost型として解釈しています。
ただ、これはあくまで「解釈」であり、Post型のプロパティが存在することは保証されていません。
そのため、プロパティpostIdは型定義上、存在するように見えて実際には存在していないため、undefinedを返します。

今回は外部 API に対する自前の型定義が間違っていたために起きました。では、型定義が間違えなければ?と思うのですが、外部 API の場合、仕様変更により想定した JSON の形式でなくなることはあり得ます。そうなれば、同じようなことが起きるでしょう。

もしちゃんとプロパティチェックしようとすると、かなり長ったらしくなります。流石にこれは面倒です。

console.log("id" in result && typeof result.id === "number" && result.id);

このため、型定義に対応するプロパティが存在する、またはプロパティが想定通りになっているかを簡単にチェックするために
Schema Validation ライブラリが必要です。代表例でいえば Zod 、Valibot です。

Schema Validation をやってみる

上記の問題を把握した上で、実際に Validation 処理を加えてみます。
今回は新興であり軽量である Valibot を使ってみます。

Schema Validation ライブラリでは型ではなく Schema というオブジェクトを定義して、それを元に Validation する事が多いです。
先ほどの関数に Validation を加えてみます。

import { number, object, type Output, parse, safeParse, string } from "valibot";

// Schemaの定義
const PostSchema = object({
  userId: number(),
  id: number(),
  title: string(),
  body: string(),
});

// Schema から型への変換
type Post = Output<typeof PostSchema>;

const fetchPosts = async (id: number): Promise<Post> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  const json = await res.json(); // この時点ではany型
  const result = parse(PostSchema, json); // プロパティが存在することを検証、失敗するとValiErrorをThrow
  return result;
};

parse関数によってプロパティの値の存在を検証しているため、呼び出し先にて安全に取り扱うことができます。

例外を Throw したくない場合はsafeParseで成功した場合は値を返して、失敗したらエラーログを出して返り値は null で返す、ということもできます。

const fetchPosts = async (id: number): Promise<Post | null> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  const json = await res.json();
  const result = safeParse(PostSchema, json);
  if (result.success) {
    return result.output;
  } else {
    console.error(result.issues);
    return null;
  }
};

不要なプロパティを削除する

parse 関数によって不要なプロパティを削除することも可能です。

例えばサーバー側で ORM を利用してユーザーのデータを取得して、クライアント側に JSON として返す際に、{email: "hoge@example.com"}みたいな値をクライアントに渡したくないとします。

const UserSchema = object({
  id: string(),
  name: string(),
  email: string([email()]),
});

// emailを削除したSchemaを定義する
const SimplifiedUserSchema = omit(UserSchema, ["email"]);

// emailが削除される
const result = parse(SimplifiedUserSchema, {
  id: "1",
  name: "hoge",
  email: "hoge@example.com",
});
console.log(result); // { id: '1', name: 'hoge' }

JavaScript 標準に存在する delete 演算子でも似たようなことはできるのですが、手続的な書き方になるため、複雑な条件分岐が発生すると「最終的にどんなプロパティなのかわからない」ということになります。
結局のところ型安全にならなくなります。

// ts(2790) のためemailがOptional型にする必要がある
type User = { id: string; name: string; email?: string };
type SimplifiedUser = Omit<User, "email">;

const user: User = {
  id: "1",
  name: "hoge",
  email: "hoge@example.com",
};
delete user.email;
// delete user.emailを忘れても型検査が通ってしまう
const simplifiedUser = { ...user } satisfies SimplifiedUser;
console.log(simplifiedUser); // { id: '1', name: 'hoge' }

ts(2790)のエラーを回避するために、削除されるプロパティは Optional にする必要があります。

'delete' 演算子のオペランドはオプションである必要があります。ts(2790)

まとめと余談

今回は詳しくは触れていませんが、Schema Validation ライブラリには文字列が email 形式か、最小文字数より多いかなどの情報を持たせて値を検証することもできます。

今では<input type="email">でブラウザ側の Validation をかける事はできますが、基本的にはクライアントから送られる値を過信できません。サーバー側でも Validation を行い、値が想定通りか検証する必要があります。

余談ですが、Response が Optional だらけの API を取り扱ったことがあるのですが、results?.at(0)?.user?.info?.emails.find(email => email.type === "primary") ?? ""みたいなオプショナルチェーン地獄になり、かなり疲弊しました。

required なき OpenAPI が憎い

Discussion