TypeScriptでObjectの型ガヌド関数を盎感的に定矩できるラむブラリを䜜った

公開日: 2022-02-26

曎新日: 2022-02-26

  • Tech
  • TypeScript
  • npm

Zenn蚘事はこちら。

はじめに

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であれば、valueはstring型である」ずいうこずが保蚌されおいたす。

たた、以䞋のようにしお型ガヌド関数(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です。以䞋に぀いお順番に解説しおいきたす。

※ 珟圚の仕様ずは異なる箇所がありたす。詳现はREADMEをご芧ください。

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()などは型ガヌド関数あるいは型ガヌド関数を返す関数です。

※ 以降stringやoptional()のような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を参考にしたした。

他のラむブラリずの比范

typescannerのscanner()関数ず䌌たようなものを持぀ラむブラリもありたしたが、関数名が気に入らなかったり、党䜓の芋通しがあたり良くなかったり、ラむブラリ偎で甚意しお欲しかったlist()やscan()関数のような機胜がなかったりず、「これだ」ず思えるものを芋぀けるこずができたせんでした。あず、以前からnpmパッケヌゞを䜜っおみたいずう願望があったので今回は自分で䜜っおみたした。

さいごに

気になった方は是非むンストヌルしお䜿っおみおくださいPull Request等も倧歓迎です。

参考

typescanner - npm https://www.npmjs.com/package/typescanner
型ガヌド - TypeScript Deep Dive 日本語版 https://typescript-jp.gitbook.io/deep-dive/type-system/typeguard

yona

yona

琉球倧孊の理孊郚に所属しおいる倧孊3幎生です。 趣味ず仕事でWebアプリ開発やシステム開発をしおいたす。