型ガードを積み木みたいに組み立てる:is-kit で再利用可能なロジックにする
みなさん、こんにちは!
10月入ってもまだ半袖半パンで外でてるフロントエンドエンジニアの @nyaomaru です!
TypeScript を実装しているときに、型ガード関数(type guard)を書くことは多いと思います。
でも、
- いたるところで見かける同じような
isXXX - コピペ → 微修正の無限地獄
- 複雑な型パズル化で保守がツラい
…みたいな沼にハマること、ありません?
そこで今日は、型ガードを「再利用可能なロジック」に昇格させるための設計パターンを紹介します。道具としては僕のライブラリ is-kit(ゼロ依存・軽量) を使いますが、考え方は素の TypeScript でも応用できます。
ほんなら、一緒に見てこな!
型ガード関数って?
超ざっくりいうと、実行時チェックと型の絞り込みを両立する関数やな!
// 引数が string かどうかを実行時に判定し、true の分岐では型が string に絞られる
function isString(value: unknown): value is string {
return typeof value === 'string';
}
これは簡単なやつやけど、ちょっと実務よりにするとこんな感じやな。
// 単純なユーザーの型
type SimpleUser = {
/** ID */
id: number;
/** 名前 */
name: string;
};
// 引数に渡した型が SimpleUser であることを保証する。保証された場合、型推論によって SimpleUser 型が確定する
function isUser(value: unknown): value is SimpleUser {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as any).id === 'number' &&
typeof (value as any).name === 'string'
);
}
つまり、if (isUser(x)) の中だけで x.id が number になる、アレや。
便利やけど、毎回手で書くのは非効率。そして合成や部分流用がしづらいのが悩み。
♻️ 型ガードを“定義”と“合成”で組み立てる
型ガードは細かく分解して再利用できるようにすると、他の型ガードでも共通利用できるからオススメやで~!
例: User の shape を宣言的に
まず、先の isUser は使い勝手が悪い。汎用性が低いから、再利用できるように、isXXXをそれぞれ宣言してみよか。
// 引数に渡した型が object であることを保証する。
function isObject(value: unknown): value is object {
return typeof value === 'object';
}
// 引数に渡した型が null であることを保証する。
function isNull(value: unknown): value is null {
return value === null;
}
// 引数に渡した型が string であることを保証する。
function isString(value: unknown): value is string {
return typeof value === 'string';
}
// 引数に渡した型が number であることを保証する。
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
// 引数に渡した型が SimpleUser であることを保証する。
function isUser(value: unknown): value is SimpleUser {
return (
// typeof 'object' は null も含むので、!isNull とセットで使う
isObject(value) &&
!isNull(value) &&
isNumber((value as any).id) &&
isString((value as any).name)
);
}
こうすることで、各型ガードを再利用できる形に細かく分解しているから、それらを組み立てることで新しい型ガードを表現できるねんな~。
object の型ガードはホンマはこれやと Array や Date も object 扱いされてしまって型が緩いから、
// プレーンなオブジェクト判定(Array/Dateなどのオブジェクト“風”を弾く)、型推論によって Record<string, unknown> 型が確定する
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false;
const proto = Object.getPrototypeOf(value);
return proto === null || Object.getPrototypeOf(proto) === null;
}
みたいに定義する必要がありそうや。
でも、これを is-kit で表現するとこうなる。
import { isNumber, isString, struct } from 'is-kit';
const isUser = struct({
id: isNumber,
name: isString,
});
めっちゃシュッとしてへん?しかも型安全やねんなぁ~!
なんでかというと
-
structはスキーマからInferSchemaを組み立てるので、isUserを使ったあとの値にはid: numberとname: stringが確定 -
structはプレーンオブジェクトだけを受け付けるので、配列や Date などの「objectっぽい」ものを誤って通さない -
struct(schema, { exact: true })で余計なキーを弾ける(境界を固めやすい)
is-kit には、型ガードの最小単位(プリミティブ)と、それを合成する関数が揃ってるで~!
📘 ここまでのまとめ
手書きの型ガードを小さな isXXX に分解しておくと、再利用しやすくなる。
でも、現場では「条件を追加したい(AND)」とか「いくつかの条件のどれかを満たせば OK(OR)」みたいなケースも多い。
これを if 文でつなぐと地獄になるから次にいこか~。
🛡️ 複雑な型ガードをスマートに表現する
特殊条件の組み合わせ(例:偶数 ID で prefix が 「SP_」 のときだけ有効)みたいなで、 if 文が雪だるま式に増えて地獄になるやつ、あるやん?
例: ややこしい型ガード(Before → After)
例えば、以下のような型ガードがある。
// 単純なユーザーの型
type SimpleUser = {
/** ID */
id: number;
/** 名前 */
name: string;
};
// 特殊なユーザーの型
type SpecialUser = {
/** 偶数のID */
id: number;
/** 「SP_」が prefix として付与される */
name: string;
/** 特殊な設定を持っている */
specialSetting: string;
};
// 引数に渡した型が SpecialUser であることを保証する。
function isSpecialUser(value: SimpleUser | SpecialUser): value is SpecialUser {
return (
isObject(value) &&
!isNull(value) &&
isNumber((value as any).id) &&
isString((value as any).name) &&
(value as any).id % 2 === 0 &&
(value as any).name.startsWith('SP_') &&
isString((value as any).specialSetting)
);
}
これはさっきと違って、id が偶数かつ name が 「SP_」から始まる場合に、SpecialUser として扱うって型やな。
この時だけ、User は specialSetting を持っているわけやから、型を狭めないと specialSetting 利用できない case が出てくる。
若干リファクタすると、こうなる。
// ユーザーの共通型
type UserBase = {
/** ID */
id: number;
/** 名前 */
name: string;
};
// 単純なユーザーの型
type SimpleUser = UserBase;
// 特殊なユーザーの型: 偶数の ID と name には「SP_」が prefix として付与される
type SpecialUser = UserBase & {
/** 特殊な設定を持っている */
specialSetting: string;
};
// 引数に渡した型が偶数の number であることを保証する。
function isEven(value: unknown): value is number {
return isNumber(value) && value % 2 === 0;
}
// 引数に渡した型が「SP_」から始まる string であることを保証する。
function isSpecialName(value: unknown): value is string {
return isString(value) && value.startsWith('SP_');
}
// 引数に渡した値が string であることを保証する (実質的に存在チェックとなっている)
function isSpecialSetting(value: unknown): value is string {
return isString(value);
}
// 引数に渡した型が SpecialUser であることを保証する。
function isSpecialUser(value: SimpleUser | SpecialUser): value is SpecialUser {
return (
isObject(value) &&
!isNull(value) &&
isEven((value as any).id) &&
isSpecialName((value as any).name) &&
isString((value as any).specialSetting) &&
isSpecialSetting((value as any).specialSetting)
);
}
こうやって型ガードを合成することで、複雑な型ガードにも対応できる。やから細かく定義して再利用する価値があるわけやな。
これを is-kit を使って表現するとこうなる。
import {
and,
guardIn,
isNumber,
isString,
predicateToRefine,
struct,
} from 'is-kit';
const isSimpleUser = struct({
id: isNumber,
name: isString,
});
const isSpecialUser = struct({
// shape(isNumber)に値制約(偶数・有限)を合成
id: and(
isNumber,
predicateToRefine((id: number) => id % 2 === 0)
),
// shape(isString)に prefix 制約を合成
name: and(
isString,
predicateToRefine((name: string) => name.startsWith('SP_'))
),
specialSetting: isString,
});
const isSpecialUserInUnion = guardIn<SimpleUser | SpecialUser>()(isSpecialUser);
ほんでこうやって使う。
// 使い方例
declare const candidate: SimpleUser | SpecialUser;
if (isSpecialUserInUnion(candidate)) {
candidate.specialSetting.toUpperCase(); // candidate: SpecialUser
}
struct で shape を宣言しつつ、 and と組み合わせるだけで値制約まで表現できる。
最後に、guardIn を挟めば union でも安全に狭められる、というのが is-kit のポイントや!
型安全かつ宣言的に利用できて、ええなぁ!
しかも、この書き方なら一度定義したロジックをどの型ガードにも再利用できるんやで~ 😸
補足:
guardInは union 型の中で特定の型を安全に絞るためのヘルパーやけど、
通常のif (isSpecialUser(xxx))でも TypeScript が正しく推論してくれるケースが多いで!
🎯 まとめ
型ガードは「その場しのぎの防衛線」じゃなくて、再利用できるロジックの積み木にできる。
struct で形を決めて、predicateToRefine で条件を足して、and / or で合成するだけ。
型安全に再利用できる isXXX を短い行数で宣言できる。
現場の isXXX を 1 個だけ is-kit 化してみて。違い、すぐ実感できるはずやで。
気に入ったら 🌟 つけてってなぁ~!😻
Discussion