Vitest の expect.unreachable で条件分岐を伴うテストで型を絞り込める
expect.unreachable、かなり便利
Zod や Valibot などで作成した schema について、Vitest で Unit テストを記述する際に、「成功したかどうか」の分岐がでた時に少し困りませんか?
import * as v from "valibot";
export const UserSchema = v.object({
name: v.string(),
age: v.number(),
});
// 🔴 うまくいかない例
import { UserSchema } from "./user";
import * as v from "valibot";
it("should successfully parse a valid user", () => {
const user = { name: "John Doe", age: 25 };
const result = v.safeParse(UserSchema, user);
expect(result.success).toBe(true);
// expect だけでは型が絞り込まれず、その場合 output が unknown 型になるので
// @ts-expect-error
expect(result.output.age).toEqual(25);
// ^^^ 'age' プロパティは存在しません。
});
expect だけでは型が絞り込まれないため、型チェックで偽陽性(実行時には正しく動くはずなのに、型チェック時にはエラー)になってしまいます。
result.output にはアクセスできるのですが、型が unknown のままで UserSchema の型には絞り込まれていないため、 age プロパティにアクセスできません。
参考: `output` のアクセス自体は失敗せず、`unknown` 型になる理由
Valibot の safeParse の戻り値の型定義を見てみると、以下のようになっています。
- 成功(
success === true)のとき、outputの型が決まる - 失敗(
success === false)のとき、outputの型がunknownになる- 「
outputプロパティが存在しない」とはならない
- 「
今回のようなコードを書く時の利便性を考慮してのことですかね…?実際に、 age のような個別のプロパティではなく、 output の内容全体を比較しさえすれば良いなら、型エラーを起こさずに書けます。
また、「✅️完全版コード」のようにして型チェックを有効化したとしても、(expect の定義がゆるいので)型チェックが特に厳しくなるわけでもありません。
なので、実は、safeParse は、unreachable のメリットの説明のためには少し不完全ですが、論理的に正しい主張のはずなのでそれに免じて許してください。🙇
// 実はそんなに問題がない
import { UserSchema } from "./user";
import * as v from "valibot";
it("should successfully parse a valid user", () => {
const user = { name: "John Doe", age: 25 };
const result = v.safeParse(UserSchema, user);
expect(result.success).toBe(true);
expect(result.output).toEqual({ name: "John Doe", age: 25 });
// ^^^^^^ unknown 型
});
// ✅️ 完全版コード
import { UserSchema } from "./user";
import * as v from "valibot";
it("should successfully parse a valid user", () => {
const user = { name: "John Doe", age: 25 };
const result = v.safeParse(UserSchema, user);
if (!result.success) {
expect.unreachable("Should not fail safeParse");
}
// 🔴書き間違いだが、toEqual の型定義が緩く、型チェックで検出されない。
expect(result.output).toEqual({ name: "John Doe" });
});
そこで使えるのが、expect.unreachable です。
// ✅️ 完全版コード
import { UserSchema } from "./user";
import * as v from "valibot";
it("should successfully parse a valid user", () => {
const user = { name: "John Doe", age: 25 };
const result = v.safeParse(UserSchema, user);
if (!result.success) {
expect.unreachable("Should not fail safeParse");
}
// ✅️ output の型が、狙い通りに絞り込まれている
expect(result.output.age).toEqual(25);
});
テストをわざと失敗させてみると、以下のようなメッセージを出してくれます。

▼ ドキュメント
assert との違い
実は、この記事を書いたきっかけになったのは、Vitest の assert を紹介している以下の記事です。
assert も expect.unreachable と同様に、到達不能コードを TypeScript に教えて型を絞り込むのに使えます。
どちらも似たりよったりですが、個人的に expect.unreachable には「ただの if 文で、通常のプロダクションのコードと同様に(型ガードとして有効なガード節として)書けるので、assert の API の知識が不要」という利点があると思います。
(もちろん「テストを全体的に expect を使って書いている場合には」という条件付きですが…)
Q.どんなカラクリになってるの? A. never 型による大域脱出の明示
expect.unreachable の型定義を見てみると、以下のようになっています。
interface ExpectStatic {
/* 中略 */
unreachable: (message?: string) => never;
「never を返す」という見慣れないシグネチャになっています。このような関数を呼び出すと、「大域脱出」として TypeScript に認識されます。これに条件分岐を組み合わせることで、早期リターンによる型ガードと同様に、型の絞り込みができているんですね。
同じ仕組みを使った例として: Next.js の notFound 関数 があります。今回と同様、条件分岐の中で notFound を呼び出すことで、
- id が undefined のときは Not Found のエラーを見せる
- → そうでない場合の分岐では id が undefined でないことが保証されている
といった型チェックが可能です。
import { notFound } from 'next/navigation'
import { fetchUser } from './fetch-user'
export default async function Profile({ params }: PageProps<'/users/[id]'>) {
const { id } = await params
const user = await fetchUser(id)
// ^? : User | undefined 型
if (!user) {
notFound()
}
// 以降、user が undefined でないことが保証されている
}
以上。
Discussion