🛡️

TypeScriptでObjectの型ガード関数を直感的に定義できるライブラリを作った

10 min read 2

はじめに

typescannerというObjectの型ガード関数を直感的に定義できるライブラリを作りました。
詳しい使い方はREADME.mdに書いたので、この記事では簡単な紹介をしていこうと思います。

https://github.com/yona3/typescanner

Example

最初に型ガードの説明を書いていたら少し長くなってしまったので、先にREADME.mdのExampleを載せておきます。(ご存知の方は型ガードの説明部分を読み飛ばしていただいても問題ありません。)

// define the union type
const Lang = {
  ja: "ja",
  en: "en",
} as const;
type Lang = typeof Lang[keyof typeof Lang]; // "ja" | "en"

const langList = Object.values(Lang);

type Post = {
  id: number;
  author: string | null;
  body: string;
  lang: Lang;
  isPublic: boolean;
  createdAt: Date;
  tags?: string[] | null;
};

// create a scanner
const isPost = scanner<Post>({
  id: number,
  author: union(string, Null),
  body: string,
  lang: list(langList),
  isPublic: boolean,
  createdAt: date,
  tags: optional(array(string), Null),
});

const data = {
  id: 1,
  author: "taro",
  body: "Hello!",
  lang: "ja",
  isPublic: true,
  createdAt: new Date(),
  tags: ["tag1", "tag2"],
} as unknown;

// scan
const post = scan(data, isPost);

post.body; // OK

型ガード(Type Guard)とは?

TypeScriptを使っているとany型やunknown型、union型など、実際の値の型が不明な変数を扱う場面がよく出てきます。その中でもfetch APIなどでAPIからデータを取得する際は、複数のプロパティを持ったオブジェクトを扱うことになります。fetch APIの場合、取得したデータの値はany型になります。ジェネリクスやasを使って戻り値の型を指定することもできますが、一部のプロパティに想定外の値が入っていたり、そもそも必要なプロパティが無いといった場合でも、データ取得時にエラーは起こりません。実際にそのプロパティを参照するタイミングで初めてエラーが発生します。
ほとんどの場合はどこかでデータの値を使うので、開発時にバグを発見することができます。しかし、実際にその値を参照するまではデータの不備や型定義のミス等に気付けません。この問題はデータを取得してすぐに型を検証し、問題があればエラーにするという方法で解決できます。それを実現するために必要なのが型ガード(Type Guard)です。

fetch APIでデータを取得する場合を例に挙げましたが、外部サービスのAPIなどはレスポンスが急に変わることはないのでガチガチに型ガードするメリットはあまりないと思っています。私の場合はPOSTリクエストに含めるBodyの作成時など、Objectを直接操作する複雑な処理の後でバリデーションを行う際に使うケースが多いです。

以下の例ではstring型かnumber型かを判定しています。

const foo = "a" as unknown;

if (typeof foo === "string") {
  foo.toUpperCase(); // ok: string型として扱われる
}

if (typeof foo === "number") {
  foo * 1; // ok: number型として扱われる
}

型ガードは型アサーションと違い、実際の値の型を判定します。つまり、「typeof value === "string"trueであれば、valuestring型である」ということが保証されています。

また、以下のようにして型ガード関数(Type Guard関数)を定義することもできます。

// string型がどうかをチェックする関数
const isString = (value: unknown): value is string => typeof value === "string";

if (isString(foo)) {
  foo.toUpperCase(); // ok: string型として扱われる
  foo * 1; // error!
}

関数の戻り値の型をvalue is Typeとし、任意の条件文をreturnすることで定義できます。

上記のisString()の例でわかるように、型ガード関数を使うとtypeof value === "string"と書くのに比べてスッキリ書くことができるので便利ですが、中身の条件文さえ満たせばstring型として扱われてしまうという点には注意が必要です。

型ガード関数を定義することで、複数のプロパティをもったObjectの型ガードも実現できます。

type Foo = {
  a: string;
  b: number;
}

const foo = {
  a: "a",
  b: 1,
} as unknown;

// 全てのプロパティを"Optional"とし、全ての値についてunknown型とした型を返す
type WouldBe<T> = { [P in keyof T]?: unknown };

// Objectかどうかを判定する型ガード関数
const isObject = <T extends Record<string, unknown>>(value: unknown): value is WouldBe<T> =>
  typeof value === "object" && value !== null;

// Foo型かどうかを判定する型ガード関数
const isFoo = (value: unknown): value is Foo =>
  isObject<Foo>(value) && typeof value.a === "string" && typeof value.b === "number";

if (isFoo(foo)) {
  foo.a.toUpperCase(); // ok
  foo.b * 1; // ok
}

上の例は@suinさんのこちらの記事で紹介されているコードとほぼ同じなので、詳しい解説は省きます。素晴らしい記事をありがとうございます!

https://qiita.com/suin/items/e0f7b7add75092196cd8

型ガード関数の問題点

型ガード関数を使えば、複数のプロパティを持つ複雑なObjectの型ガードも実装することができます。私もつい最近までasを多用していたのですが、型ガード関数の存在を知ってからは必要に応じてしっかり型ガードをするようになりました。しかし、Objectの型ガードを実装する上でいくつか問題が起きました。

可読性の低さ

isFooの例を見ればわかる通り、コードの見通しが悪いです。Foo型のオブジェクトよりもプロパティが多く、typeof演算子やinstanceof演算子でチェックできない型 ("a" | "b"string[]など) があると、さらに複雑になります。

// union型を定義
const Lang = {
  ja: "ja",
  en: "en",
} as const;
type Lang = typeof Lang[keyof typeof Lang]; // "ja" | "en"

const langList = Object.values(Lang); // ["ja", "en"]

// Post型を定義
type Post = {
  id: number;
  author: string | null;
  body: string;
  lang: Lang;
  isPublic: boolean;
  createdAt: Date;
  tags?: string[] | null;
};


// Postの型ガード関数
const isPost = (value: unknown): value is Post =>
  isObject<Post>(value) &&
  typeof value.id === "number" &&
  (typeof value.author === "string" || value.author === null) &&
  typeof value.body === "string" &&
  langList.includes(value.lang as any) &&
  typeof value.isPublic === "boolean" &&
  value.createdAt instanceof Date &&
  (
    value.tags == null ||
    (
      Array.isArray(value.tags) &&
      value.tags.every((item) => typeof item === "string")
    )
  );

// Post型の条件を満たすデータ
const data = {
  id: 1,
  author: "taro",
  body: "Hello!",
  lang: "ja",
  isPublic: true,
  createdAt: new Date(),
  tags: ["tag1", "tag2"],
} as unknown;

if (isPost(data)) {
  console.log(data.body.trim()); // ok
}

今回は型ガード関数を使わずに実装してみました。ご覧の通りこのままでは可読性が低く、とても見づらいです。次に、isString()のような独自の型ガード関数を用いてisPost()をリファクタリングしてみます。

型ガード関数を定義するのに手間がかかる

リファクタリングしたコードがこちらです。

// リファクタリングしたisPost()
const isPost = (value: unknown): value is Post =>
  isObject<Post>(value) &&
  isNumber(value.id) &&
  (isString(value.author) || isNull(value.author)) &&
  isString(string) &&
  isList(value.lang, langList) && // Lang型の型ガード関数 ("ja" | "en")
  isBoolean(value.isPublic) &&
  isDate(value.createdAt) &&
  (isNull(value.tags) || isUndefined(value.tags) || isArray(value.tags, isString));

これで可読性の低さは少し改善できました。isList()だけ説明しておくと、第一引数で渡した値が第二引数で渡された配列に含まれるかどうかをチェックする型ガード関数になっています。

型ガード関数の定義の部分は省きましたが、今回のリファクタリングで使用した関数だけでも定義するのがかなり大変です。isString()のようなプリミティブ型の型ガード関数は特にそうですが、基本的な型ガード関数は毎回同じ実装になるので、ライブラリとしてまとめて用意されていると便利です。

どのプロパティに問題があるのかわかりづらい

関数の可読性の問題はなくなりましたが、実際に型ガード関数を使ってみるとまた問題が起こりました。

const post = await fetchPost();
if (!isPost(post)) throw new Error("post is invalid.")

// postでなにかする

これは型の検証に失敗した際にエラーを投げるという処理です。

型ガード自体はしっかりできているのですが、このエラーメッセージからは「どのプロパティがおかしいのか」をすぐに判別することができません。postの中身をconsole.logで確認するなど、実際の値と型ガード関数の中身を比較して間違い探しをする必要があります。Objectのプロパティの数が多いほど大変です。

これを解決するためには型ガード関数をラップし、エラー発生時に"問題のあるプロパティ"の情報を含むエラー文を吐かせる関数を実装する必要がありそうです。

typescannerの紹介

前置きが長くなってしまいましたが、以上の問題を解決するために作ったライブラリがtypescannerです。以下について順番に解説していきます。

typescannerの特徴

  1. isString()を含む基本的な型ガード関数 + 独自の型ガード関数
  2. Objectの型ガード関数を直感的に定義できるscanner()関数
  3. 型ガードを行った上で検証済みの値を受け取るscan()関数

1. 基本的な型ガード関数

typescannerで用意している型ガード関数は以下のとおりです。isArray以降の型ガード関数は第一引数に検証したい値、第二引数以降に検証に必要なもの(型ガード関数や配列、コンストラクターなど)を受け取る形になっています。

// primitive

isString("a") // true

isNumber(1) // true

isBoolean(true) // true

isUndefined(undefined) // true

isNull(null) // true

isDate(new Data()) // true

isSymbol(Symbol("a")) // true

isBigint(BigInt(1)) // true

// isObject

isObject<T>(value) // <T>(value: unknown) =>  value is WouldBe<T>

// isArray

isArray(["a", "b"], isString) // string[]

isArray<string | number>(["a", 1], isString, isNumber) // (string | number)[]

isArray(["a", null, undefined], isString, isNull, isUndefined) // (string | null | undefined)[]

// isOptional

isOptional("a", isString) // true

isOptional(undefined, isString) // true

// isList

isList("ja", langList) // true

// isInstanceOf

try {
  ...
} catch (error) {
  if (isInstanceOf(error, Error)) {
    error.message // OK
  }
}

2. scanner()関数

scanner()関数はObjectの型ガード関数を直感的に定義できる関数です。

「直感的に定義できる」 ← 主観です

基本

scanner()関数を使って先程の例でも登場したPost型の型ガード関数であるisPost()を書き換えてみます。

type Post = {
  id: number;
  author: string | null;
  body: string;
  lang: Lang;
  isPublic: boolean;
  createdAt: Date;
  tags?: string[] | null;
};

// scanner()関数で定義したPost型の型ガード関数
const isPost = scanner<Post>({
  id: number,
  author: union(string, Null),
  body: string,
  lang: list(langList),
  isPublic: boolean,
  createdAt: date,
  tags: optional(array(string), Null),
});

scanner()関数はジェネリクスで指定した型に合わせて各プロパティに対して型ガード関数を設定することでObjectの型ガード関数を返してくれます。つまり、scanner()の引数として渡しているObjectの中で登場するstring,number,list(),optional()などは型ガード関数(あるいは型ガード関数を返す関数)です。

以降stringoptional()のようなscanner()関数を使用する際に使う型ガード関数をfieldsと呼びます。

stringのようなプリミティブ型のfieldsの中身はisString()と全く同じですが、Type Ailiasで型定義する感覚に近づけたかったのでそのようにしています("直感的に定義できる"はここからきています)。
もちろん好みでなければstringの代わりにisStringと書くことも可能です。

fieldsの拡張

また、独自の型ガード関数を用いてfieldsを拡張することも可能です。

type Foo = {
  a: string;
  b: number;
  c: boolean;
  d: Date;
  e: string[];
  f?: string;
  g: "a" | "b" | "c";
  h: string | null;
  i: string | number;
  j: number;
};
  
// 独自の型ガード関数 (field)
const even = (value: unknown): value is number =>
  isNumber(value) && value % 2 === 0;

const isFoo = scanner<Foo>({
  a: string,
  b: number,
  c: boolean,
  d: date,
  e: array(string),
  f: optional(string),
  g: list(["a", "b", "c"]),
  h: union(string, Null),
  i: union<string | number>(string, number),
  j: even, // Custom field
});

エラー発生時の挙動

そして型ガード関数の問題点で挙げた「どのプロパティに問題があるのかわかりづらい」という問題は、"問題のあるプロパティ"の情報を含むエラー文を吐かせることで解決しています。

地味な機能ではありますが、とても便利で気に入っています。

// Error: value.key does not meet the condition.
if (isFoo(data)) {
  ...
}

3. scan()関数

scan()関数は第一引数に検証したい値、第二引数以降に型ガード関数を渡すことで検証済みの値が受け取れます。

// success
const data = scan(foo as unknown, isFoo);
data.a // OK

// Error!
const data = scan(bar as unknown, isFoo); // Error: value.key does not meet the condition.

scan()関数はZennで最近話題になっていた@yuitosatoさんのas-safelyを参考にしました。

https://zenn.dev/yuitosato/articles/fdbc464f31c292

他のライブラリとの比較

typescannerのscanner()関数と似たようなものを持つライブラリもありましたが、関数名が気に入らなかったり、全体の見通しがあまり良くなかったり、ライブラリ側で用意して欲しかったlist()scan()関数のような機能がなかったりと、「これだ!」と思えるものを見つけることができませんでした。あと、以前からnpmパッケージを作ってみたいとう願望があったので今回は自分で作ってみました。

さいごに

気になった方は是非インストールして使ってみてください!Pull Request等も大歓迎です。何か間違っていたりおかしな部分があればコメント等で指摘していただけるとありがたいです。

参考

https://www.npmjs.com/package/typescanner
https://typescript-jp.gitbook.io/deep-dive/type-system/typeguard

Discussion

僕の記事の紹介ありがとうございます!Twitterで偶然見つけました。

ユーザー定義型ガードの関数作成はみんな苦労しているところだと思います。typescannerがそんな人達の役にたっていくといいですね!

ご本人からコメント頂けて嬉しい限りです…🙏
ありがとうございます!

ログインするとコメントできます