# TOKOS Tech Blog --- --- > 株式会社TOKOSのエンジニアが技術的な知見やノウハウを共有するテックブログです。React、Next.js、TypeScript などフロントエンド技術を中心に情報を発信しています。 --- --- - URL: https://blog.to-ko-s.com --- - 言語: 日本語 --- --- ## 【Zod】v4.5の新機能とパフォーマンス改善をまとめて解説 - URL: https://blog.to-ko-s.com/articles/zod-v4-5 - 著者: 杉田侑祐 - 公開日: 2026-09-24 - タグ: TypeScript, パフォーマンス > Zod v4.5の新機能とパフォーマンス改善を解説します。スキーマをAOTコンパイルするz.compile()、メモリ最大9.8倍削減、z.toZod()の型安全、そして文字列長のコードポイント化などアップグレード前に確認したい破壊的変更まで扱います。 ## はじめに ### この記事の概要 こんにちは、株式会社TOKOSのスギタです! Zod v4.5が2026年8月28日にリリースされました。 新機能とパフォーマンス改善、そしてアップグレードで挙動が変わる箇所をまとめます。 ### 対象読者 - すでにZodを業務で使っているTypeScript開発者の方 - v4系を使っていて、v4.5へのアップグレードを検討している方 - `z.compile()`を自分のプロジェクトで使えるか判断したい方 ### この記事で扱う内容、扱わない内容 扱う内容は下記です。 - Zod v4.5で追加された機能 - v4.4からのパフォーマンス改善点 - アップグレードで挙動が変わる箇所 扱わない内容は下記です。 - Zod自体の入門(スキーマ定義や`parse`の基本) - Zod v3からv4への移行 - Zod Mini(Zod本体の軽量版)の詳細(`z.compile()`のバンドルサイズに関わる部分だけ触れます) - `react-hook-form`など他ライブラリとの連携 ## Zod v4.5で入った変更の全体像 新機能の追加とパフォーマンスの改善、そして仕様をより正確にする破壊的変更が入っています。 その中でも気になるものだけ取り上げます! | 変更点 | 内容 | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | | `z.compile()` | スキーマの検証を1本のJavaScript関数に書き出しておく新機能。キーの多いオブジェクトで`parse`が最大10.2倍速い | | メモリ削減と失敗時の高速化 | 内部構造の見直しによる改善で、コードの書き換えは不要。スキーマ1つが使うメモリが最大9.8分の1、失敗する`safeParse()`は7.6倍速い | | `z.validate()` | 値が通るかどうかだけを真偽値で返す型ガード。エラーを組み立てない分、不正な入力に対して`.safeParse().success`より最大16倍速い | | `z.toZod()` | 先に決めた型とスキーマが一致するか検証。`satisfies z.ZodType`では見逃していた余分なキーや`z.any()`を弾ける | | 文字列長がコードポイント単位に | `.min()`と`.max()`の数え方がUTF-16のコードユニットからUnicodeのコードポイントへ変わり、絵文字を含む入力で結果が変わる | ここに入らなかった追加分は「その他の新機能」でまとめて扱います。 `z.deepPartial()`や`z.creditCard()`、循環参照を含むデータのparseなどが該当します。 ## z.compile() Zodはキーが増えるほど、parseに時間がかかります。 `z.compile()`は、この作業を先に済ませて1本のJavaScript関数に書き出しておく機能です。 ```ts title="z.compile()の基本" showLineNumbers {9} import * as z from "zod" const Player = z.object({ username: z.string(), bio: z.string(), xp: z.number(), }) const CompiledPlayer = z.compile(Player) CompiledPlayer.parse({ username: "billie", bio: "hello", xp: 100 }) ``` `z.compile()`が返すのは元のスキーマと同じ型です。 呼び出し側のコードは変わりません。 使う上で押さえておきたいのは次の2点です! - エラーメッセージはコンパイルの有無で変わらない - 失敗するケースは速くならない コンパイル済みの関数は、値が不正だと分かった時点で処理をやめます。 エラーを組み立てるのが元のパーサなので、メッセージは今までと変わりません。 書き出されるコードの中身は、公式ドキュメントを参照してください。 速度は公式のベンチマークが公開されています。 | スキーマ | 標準 | コンパイル済み | | -------------------------- | ----- | -------------- | | 20キーのオブジェクト | 301ns | 38ns | | 10要素のオブジェクト配列 | 377ns | 68ns | | 10要素の文字列配列 | 241ns | 33ns | | 3つのオブジェクトの`union` | 190ns | 36ns | | 5キーの`strictObject` | 117ns | 32ns | | `discriminatedUnion` | 92ns | 27ns | キーが多いほど差が開きます! 50キーのオブジェクトでは10.2倍という数値も出ています。 ### コンパイルはチェーンの最後に `.refine()`や`.extend()`、`.optional()`が返すスキーマはコンパイルされていません。 チェーンの途中で`z.compile()`を呼ぶと、その後に足したものは元のパーサで処理されます。 ```ts title="呼ぶ順番" showLineNumbers const bad = z.compile(z.string()).refine((val) => val.length > 1) // [!code --] const good = z.compile(z.string().refine((val) => val.length > 1)) // [!code ++] ``` ### コンパイルできないスキーマ すべてのスキーマがコンパイルできるわけではありません! 対応していない機能を含んでいても、`z.compile()`はエラーを投げずにそのまま返します。 `{ strict: true }`を渡しておくと、そのときにエラーを投げてくれます。 ```ts title="strictモードで確認する" z.compile(z.coerce.number(), { strict: true }) ``` ```text title="投げられるエラー" ZodCompileUnsupportedError: z.compile does not support coercion (z.coerce.number()); this schema must use the runtime parser ``` 対応していないのは次のものです。 - 非同期の`refine`・`transform`・`check` - `z.xor()` - 再帰スキーマ - `z.coerce.*` - `when`を指定したcheck - コールバックを渡した`.catch()` `.catch("fallback")`のように定数を渡す形はコンパイルできます。 非対応のものが混ざっていても、必ずスキーマ全体が諦められるわけではありません。 効く範囲は場所によって変わります。 - `object`・`array`・`tuple`・`record`・`intersection`の中なら、非対応のものだけが元のパーサに回り、外側はコンパイルされたまま - `union`のメンバーに非対応のものがある場合、コールバック形式の`.catch()`、どこかに非同期処理がある場合は全体が元のパーサに戻る ### バンドルサイズのトレードオフ コンパイラそのものが、それなりの量のコードでできています。 `z.compile()`を呼ぶか`zod/compile`をimportした時点で、その分がバンドルに乗ります。 | バンドル | compileなし | compileあり | | -------- | ----------- | ----------- | | Zod | 24.1KB | 31.1KB | | Zod Mini | 4.6KB | 13.2KB | いずれもgzip後の数値です。 Zodでは約1.3倍増加ですが、Zod Miniでは2.9倍になります。 Zod Miniを選ぶ理由はサイズなので、Miniとcompileの組み合わせは噛み合いません! 一度も呼ばなければビルド時に丸ごと削られるので、使わないなら増加はゼロです! サーバーサイドならバンドルサイズを気にせず採用できます。 クライアントで動かすバリデーションなら、7KB分の速度が得られるかを先に確かめたいところです。 ZodMiniの4.6KBが13.2KBになるのは、正直かなり厳しい数字だと感じました。Miniとcompileはどちらかひとつでいいという解釈をしています。 下記公式ブログで紹介されています! ### import "zod/compile"で全体に適用する スキーマごとに毎回1つずつコンパイルするのでは無く、アプリケーション全体でコンパイルを有効にできます。 ```ts title="グローバルに有効化する" showLineNumbers {1} import "zod/compile" // スキーマを定義するモジュールより前に置く import * as z from "zod" const schema = z.object({ name: z.string() }) schema.parse({ name: "ok" }) // 初回のparseでコンパイルされる ``` `import "zod/compile"`は読み込む順番が重要です! これより後に定義されたスキーマだけが、コンパイルの対象になります。 import文の並び順で保証しきれないときは、Node.jsの起動時に読み込みます。 ```bash title="起動時に読み込む" node --import zod/compile app.js ``` コンパイルされるのは、実際に`parse`したスキーマだけです。 定義しただけで使っていないスキーマは対象外になります。 公式ドキュメントは「This import is for applications, not libraries.」と明記しています。 ライブラリから読み込むと、利用側のスキーマまで巻き込むためです。 CSP(Content Security Policy)で`new Function`を使えない環境では、コンパイルに失敗してそのまま元のパーサで動きます。 Cloudflare Workersなどが当てはまります。 はっきり無効にしたいときは`z.config({ jitless: true })`を設定してください。 ## 何もしなくても速くなった部分 `z.compile()`は自分で呼んだときだけ効く機能です。 一方で、v4.5にはコードを変えなくても効く改善が入っています。 ### スキーマ1個あたりのメモリが最大9.8倍削減 スキーマ1つが使うメモリの比較です。 | スキーマ | v4.4.3 | v4.5 | | ----------------------- | ------ | ------ | | 10キーのオブジェクト | 82.0KB | 11.0KB | | union | 17.5KB | 2.13KB | | `z.string().min(1)` | 16.7KB | 3.37KB | | record | 16.4KB | 2.64KB | | `z.string().optional()` | 12.6KB | 1.50KB | | `z.string()` | 7.53KB | 784B | | `z.number()` | 4.44KB | 706B | メソッドの持ち方を変えたことによる効果のようです。 これまではスキーマを作った時点で、すべてのメソッドを1つずつ持たせていました。 v4.5では、実際に呼ばれたメソッドだけを後から持たせます。 一度も呼ばないメソッドの分は、まるごと不要になります! 書き方への影響はないので、次のような分割代入も引き続き動きます。 ```ts title="分割代入しても動く" showLineNumbers const { parse } = z.string() parse("some data") ``` ### safeParseの失敗が7.6倍速く `.safeParse()`は失敗するたびに`ZodError`を作り、スタックトレースまで取っていました。 v4.5でも`ZodError`自体はその場で作られますが、重い処理が後ろにずれました。 エラー文面の組み立ては`message`を読むまで走らず、`safeParse()`ではスタックの記録も省かれます。 `parse()`が投げる場合はスタックが必要なので、そちらは今までどおり記録されます。 公式の計測では、失敗する`safeParse`が6.3msから840nsになっています! フォームのように入力途中で何度も失敗する画面なら、成功より失敗のほうが回数は多いはずです。 ### z.validate() 値が通るかどうかだけを知りたい場面で、`.safeParse().success`を使っていた方は多いはずです。 この書き方だと、失敗するたびにエラーオブジェクトを組み立てる処理が走ります。 v4.5で追加された`z.validate()`は真偽値だけを返します。 ```ts title="z.validate()" showLineNumbers z.validate(z.string(), "hi") // true z.validate(z.string(), 42) // false ``` 公式によると、不正な入力に対して`.safeParse().success`より最大16倍速くなります。 `z.compile()`を通したスキーマを渡すと、差はさらに開きます。 コンパイル時に真偽値だけを返す判定用の関数も作られていて、`z.validate()`はそれをそのまま呼ぶためです。 値を組み立てる処理を通らない分だけ速く終わります。 注意したいのは、絞り込まれる型です。 ```ts title="型ガードが効くのはinput側" showLineNumbers {5-6} const Age = z.string().transform((s) => Number(s)) const value: unknown = "3" if (z.validate(Age, value)) { // ここでの value は string // transform 後の number ではない } ``` `z.validate()`が絞り込むのは、変換した後の型ではなく変換する前の型です! Zodの用語でいえば`output`ではなく`input`側になります。 非同期の`refine`や`transform`を含む場合は`z.validateAsync()`を使います。 TypeScriptでは非同期の型ガードを書けないため、こちらは`Promise`を返すだけで型は絞り込まれません。 `z.validate()`は`output`ではなく`input`に対する型ガードです。そのため、`transform`を挟んだスキーマで使うときは、絞り込まれた型を一度確認しておくと安全です。 ## z.toZod() 先に型を定義して、それに合うスキーマを書く場面があります。 例えば、外部APIの型や共有パッケージの型に合わせてバリデーションを足すときなどです。 ### satisfiesでは防げないケース これまでよく使われていたのは`satisfies z.ZodType`です。 ただしこれは「型として当てはまるかどうか」しか見ません。 必須キーの書き忘れは捕まえますが、余分なキーや`z.any()`はすり抜けてしまいます。 ```ts title="satisfiesはすり抜ける" showLineNumbers {7,11} type Player = { username: string; xp: number } // 型に存在しない admin を足してもエラーにならない z.object({ username: z.string(), xp: z.number(), admin: z.boolean(), }) satisfies z.ZodType // z.any() でも通ってしまう z.any() satisfies z.ZodType ``` この2つは手元の`tsc`でもエラーになりませんでした! ### z.toZod()なら弾ける v4.5の`z.toZod()`は、型とスキーマがぴったり一致するかどうかを見ます。 2段階に分けて呼ぶ点だけ注意してください。 ```ts title="z.toZod()で書き直す" showLineNumbers {7,11} // 1段目: 合わせたい型を渡す // 2段目: その型と一致するスキーマだけ受け付ける const PlayerSchema = z.toZod()( z.object({ username: z.string(), xp: z.number(), admin: z.boolean(), // Playerに無いキー → 型エラー }), ) // z.any()も「Playerと一致しない」のでエラー const AnyPlayer = z.toZod()(z.any()) ``` 先ほどと同じ内容ですが、今度は両方エラーになります! ```text title="tscの出力(抜粋)" error TS2345: Argument of type 'ZodObject<{ username: ZodString; xp: ZodNumber; admin: ZodBoolean; }, $strip>' is not assignable to parameter of type '$ZodObject<{ username: ZodString; xp: ZodNumber; admin: ToZodKeyMismatch; }, $strip>'. Property '"types do not match"' is missing in type '_ZodBoolean<...>' error TS2345: Argument of type 'ZodAny' is not assignable to parameter of type 'ZodAny & ToZodMismatch'. Property '"types do not match"' is missing in type '_ZodType<$ZodAnyInternals>' ``` `"types do not match"`という文字列がエラーに現れるので、原因を追いやすくなっています。 `admin`というキー名まで出るのも助かります。 スキーマが正しい場合、`z.toZod()`は渡したスキーマをそのまま返します。 戻り値の`.shape`もそのまま使えます。 `z.toZod()(schema)`という2段呼び出しは、初見だと書き間違いに見えます。それでも`satisfies`が`z.any()`を弾けないと知ってしまうと、型定義に追従させたいスキーマはこちらへ寄せたくなりました。 ## その他の新機能 ### z.deepPartial()とexactPartial() `z.deepPartial()`はZod 3にあったメソッドです。 v4でいったん削除され、v4.5で関数の形になって戻ってきました。 ネストしたオブジェクトのプロパティも再帰的に`optional`にします。 ```ts title="z.deepPartial()" showLineNumbers {6-7} const Post = z.object({ title: z.string(), author: z.object({ name: z.string(), email: z.string() }), }) const PartialPost = z.deepPartial(Post) // { title?: string; author?: { name?: string; email?: string } } PartialPost.parse({ author: {} }) // ✅ ``` 元のスキーマは変更されず、結果も`ZodObject`のままなので`.shape`や`.extend()`が使えます。 ただし、どんなオブジェクトにも使えるわけではありません。 内部で`.partial()`を使うため、`.refine()`を持つオブジェクトに行き当たると例外を投げます。 ```text title="refineを持つオブジェクトに使った場合" .partial() cannot be used on object schemas containing refinements ``` 既存のスキーマに使うなら、`.refine()`は`z.deepPartial()`のあとに付けてください。 discriminatedUnionに使うと、判別に使うキーまでoptionalになります。 判別できなくなるため、結果は通常のunionに変わります。 `.exactPartial()`は、各フィールドを`z.optional()`ではなく`z.exactOptional()`で包みます。 キーの省略は許しますが、はっきり`undefined`を渡すと弾きます。 ```ts title="exactPartialとpartialの違い" showLineNumbers {4-5} const Recipe = z.object({ title: z.string(), servings: z.number() }) Recipe.exactPartial().parse({}) // ✅ Recipe.exactPartial().parse({ title: undefined }) // ❌ Recipe.partial().parse({ title: undefined }) // ✅ ``` TypeScriptの`exactOptionalPropertyTypes`を有効にしているなら、こちらのほうが型と実行時の挙動が揃います。 `exactOptionalPropertyTypes`はTypeScriptのコンパイラオプションで、`optional`なプロパティと`undefined`を入れていいプロパティを区別する設定です。 ### z.properties() クラスのインスタンスに対して、複数のプロパティをまとめて検証できます。 これまでは`z.property()`を1つずつ並べる書き方でした。 ```ts title="v4.4まで" z.instanceof(URL).check( z.property("protocol", z.literal("https:" as string)), z.property("hostname", z.string().regex(z.regexes.domain)), ) ``` v4.5の`z.properties()`なら、オブジェクトで一度に渡せます。 戻り値が配列なので、`.check()`にはスプレッドで渡します。 ```ts title="v4.5から" showLineNumbers {2} const httpsUrl = z.instanceof(URL).check( ...z.properties({ protocol: z.literal("https:" as string), hostname: z.string().regex(z.regexes.domain), }), ) httpsUrl.parse(new URL("https://example.com")) // ✅ httpsUrl.parse(new URL("http://localhost")) // ❌ expected "https:" ``` `as string`が要るのは、`URL.protocol`の型が`string`のためです。 これを外すと、リテラル型と一致しないというエラーになります。 ### z.creditCard() クレジットカード番号のフォーマットと、Luhnアルゴリズム(クレジットカード番号の検証アルゴリズム)によるチェックサムを検証します。 ```ts title="z.creditCard()" showLineNumbers z.creditCard().parse("4111111111111111") // ✅ z.creditCard().parse("4111 1111 1111 1111") // ✅ 単一のスペース z.creditCard().parse("4111-1111-1111-1111") // ✅ 単一のハイフン z.creditCard().parse("4111 1111 1111 1111") // ❌ 区切りの連続は不可 z.creditCard().parse("4111111111111112") // ❌ チェックサム不一致 ``` 12桁から19桁を受け付けます。 区切り文字はスペースとハイフンだけで、連続はできません。 カードブランドの判別はしないので、どの発行元の番号でも通ります! ブランドを絞りたい場合は、これまでどおり自前の判定を足すことになります。 `z.creditCard()`が見てくれるのは、桁数と区切りとチェックサムまでです。 ### 循環参照を含むデータのparse 自分自身を含むデータを渡すと、v4.4まではエラーになっていました。 ```ts title="自分を含むデータ" showLineNumbers {11} const Category = z.object({ name: z.string(), get subcategories() { return z.array(Category) }, }) type Category = z.infer const input: Category = { name: "root", subcategories: [] } input.subcategories.push(input) const result = Category.parse(input) ``` 同じコードを2つのバージョンで走らせた結果です。 ```text title="実行結果" zod@4.4.3 RangeError: Maximum call stack size exceeded zod@4.5.4 result.subcategories[0] === result // true ``` 入力にあった循環は、そのまま結果にも残ります! 同じオブジェクトとして扱われるので、`===`での比較も成立します。 Zod本体では最初から有効になっています。 Zod Miniでは、スキーマを定義する前に登録します。 ```ts title="Zod Miniでの有効化" z.config({ memoizer: z.memoizer() }) ``` ### z.input()とz.output()の関数版 `z.input`と`z.output`はv4.4まで型だけでしたが、v4.5からは関数としても呼べます。 codecやpipeを含むスキーマから、片側だけを取り出せます。 ```ts title="codecの片側だけを検証する" showLineNumbers {8-9} const isoDate = z.codec(z.iso.datetime(), z.date(), { decode: (s) => new Date(s), encode: (d) => d.toISOString(), }) const Event = z.object({ name: z.string(), at: isoDate }) z.input(Event).parse({ name: "launch", at: "2024-01-01T00:00:00Z" }) // ✅ z.output(Event).parse({ name: "launch", at: new Date() }) // ✅ ``` 型としての使い方はこれまでどおりです。 `z.input()`のほうは、codecに付けたcheckを引き継ぎません。 変換した後の値を縛る条件なので、変換前のスキーマには残らないからです。 codecやpipeを含まないスキーマなら、どちらを呼んでも何も変わりません。 ### z.getDiscriminatedOption() `discriminatedUnion`から、特定のdiscriminatorを持つ選択肢を型安全に取り出せます。 ```ts title="z.getDiscriminatedOption()" showLineNumbers {5-6} const Fruit = z.object({ type: z.literal("fruit"), seeds: z.boolean() }) const Veg = z.object({ type: z.literal("vegetable"), leafy: z.boolean() }) const Produce = z.discriminatedUnion("type", [Fruit, Veg]) z.getDiscriminatedOption(Produce, "fruit") // typeof Fruit z.getDiscriminatedOption(Produce, "meat") // ❌ TypeScriptのエラー ``` これまでは`Produce.options`から`.find()`で探して、戻り値の型を手で指定していました。 存在しないdiscriminatorを渡すと、コンパイル時に落ちます。 この関数は公式ドキュメントのAPIページには見当たらず、現時点ではリリースノートだけに載っています。 手元では問題なく動きましたが、ドキュメントに載るまでは仕様が変わる可能性もありそうです。 ### そのほかの追加はドキュメントで ここまでのほかにも、細かい追加があります。 - `z.object()`のキーにsymbolを使えるようになった - `z.nanoid()`で長さを指定できるようになった - 対応言語が8言語増えた(ベンガル語・ヒンディー語・ブラジルポルトガル語など) ## 破壊的変更 リリースノートでは、これらをbug fixesとして扱っています。 どれも本来あるべき挙動に直す修正です。 そのため、これまでの挙動を前提にしていたスキーマでは、判定が変わるかもしれません。 ### 文字列長がUnicodeコードポイント単位に `.min()`・`.max()`・`.length()`の数え方が変わりました。 UTF-16のコードユニットではなく、Unicodeのコードポイントを数えます。 UTF-16とUnicodeの違いは下記を参考にしてください。 ```ts title="検証コード" showLineNumbers z.string().max(5).parse("😀😀😀😀😀") z.string().min(5).parse("😀😀😀") ``` ```text title="zod@4.4.3" max(5) => ERROR too_big: expected string to have <=5 characters min(5) => OK "😀😀😀" ``` ```text title="zod@4.5.4" max(5) => OK "😀😀😀😀😀" min(5) => ERROR too_small: expected string to have >=5 characters ``` 挙動が逆転しています! 絵文字1つはUTF-16では2コードユニットなので、`"😀😀😀"`の`.length`は6でした。 v4.5はこれを3と数えます。 ほかの言語やデータベースに合わせるための変更です。 PostgreSQLやMySQL、GoやPythonは、長さの制限をコードポイントで数えます。 `z.toJSONSchema()`が出力する`maxLength`も同じです。 変わり方は`.min()`と`.max()`で逆です。 - `.max()`は緩む方向にだけ動き、これまで弾いていた入力を通すようになる - `.min()`は厳しくなる方向にだけ動き、これまで通っていた入力を弾くようになる - `.length()`は指定した数によってどちらにも動く 表示名やユーザー名のように絵文字が入りうるフィールドで`.min()`を使っている場合、これまで通っていた値が弾かれます。アップグレード後にバリデーションのテストを流して確認してください。 ただし「見た目どおりの1文字」で数えてくれるわけではありません。 家族の絵文字のように複数の絵文字をつないだものは、今も複数文字として数えられます。 ```text title="家族の絵文字" "👨‍👩‍👧‍👦".length => 11 コードポイント数 => 7 z.string().max(1) => ERROR too_big ``` 見た目は1文字ですが、`.max(1)`は通りません! `.length()`が両方向に動く例です。 ```text title="絵文字1つに対する.length(n)" .length(1) 4.4.3 => ERROR / 4.5.4 => OK .length(2) 4.4.3 => OK / 4.5.4 => ERROR ``` 絵文字1つはコードユニットでは2、コードポイントでは1と数えるので、通る指定がそのままずれます。 v4.5でもっとも怖いのはこの変更だと思っています。仕様としては正しい方向への修正ですし、他の言語やデータベースに揃うのも納得なのですが、日本語のサービスだとニックネームや自己紹介で絵文字が普通に飛んできます。パッチバージョン感覚で上げると本番で気づくことになりかねません。 ### z.iso.datetime() RFC 3339が秒を必須としているため、それに合わせる修正が入りました。 ```text title="秒のないdatetime" z.iso.datetime().parse("2020-01-01T06:15Z") zod@4.4.3 => OK "2020-01-01T06:15Z" zod@4.5.4 => ERROR invalid_format: Invalid ISO datetime ``` 秒を省いた形式も受け付けたい場合は、公式が回避策を示しています。 ```ts title="秒なしも許容する" z.union([z.iso.datetime(), z.iso.datetime({ precision: -1 })]) ``` `{ offset: true }`を付けた場合も同じ変更が入ります。 `{ local: true }`のほうは`2020-01-01T06:15`を引き続き受け付けます。 タイムゾーンのない日時は、そもそもRFC 3339の対象外だからです。 ### そのほかの変更はリリースノートで 上の2つ以外にも、挙動の変わった箇所があります。 - `record`と`intersection`の組み合わせがTypeScriptの型どおりに動くようになった - `.strict()`に余分なキーと不正な値を同時に渡すと、issueが2件返るようになった - `__proto__`キーを常に除去するようになった - `z.ulid()`・`z.ipv6()`・`z.httpUrl()`・`z.emoji()`の判定が厳しくなった 心当たりがあれば、リリースノートで詳細を確認してください。 ## さいごに v4.5の変更は、自分で呼んで使うものと、上げるだけで効くものに分かれます。 `z.compile()`は前者です。 サーバーサイドで何度も呼ばれる処理なら、バンドルサイズを気にせず試せます。 クライアントで動かす場合は、7KBの増加と速度を比較して検討する必要がありそうです! どちらの場合も、`{ strict: true }`で黙ってフォールバックしていないか確認しておくと確実だと思います! メモリの削減と失敗時の高速化は後者です。 バージョンを上げるだけで効くので、まずはアップグレードして既存のテストを流してみてください。 --- ## Oxlintへの移行方法【まだESLint使ってるの?】 - URL: https://blog.to-ko-s.com/articles/oxlint-migration - 著者: 西原月熙 - 公開日: 2026-08-31 - タグ: TypeScript, JavaScript > Rust製の爆速LinterであるOxlintへのESLintからの移行方法を解説します。型認識lint(tsgolint)の実測値、TypeScript 7との関係、jsPlugins等、本ブログでの実例をもとに詳しく紹介します。 ## はじめに こんにちは、株式会社TOKOSの[ツキヤ](https://x.com/west_tsukiya)です! 今回は、Rust製の爆速LinterであるOxlintについて、ESLintからの移行方法をまとめていきます💪 前回のOxfmt移行記事の続編で、いよいよLinter編です。 実は以前のNext.js 15.5の[記事](/articles/nextjs-15-5)で「LinterはBiomeよりもOxc(oxlint)に期待しています」と書いていたので、ようやくその伏線を回収できた形です😎 Linterについては、今までは基本的に皆さんESLintを使われていたかと思います。 しかし、LinterについてもOxlintの方が速度面で圧倒的なので、移行を検討されている方も多いのではないでしょうか。 また、型の認識についても最近安定版が出たのでいよいよ乗り換えない理由が無くなってきましたね。 本記事では以下の内容を紹介していきます💪 - Oxlintの概要・特徴 - TypeScript 7とOxlintの関係 - 移行方法と本ブログでの実例 - 型認識lintを含めた速度の実測 - jsPluginsについて ## 対象読者 - Oxlintへの移行を検討している方 - ESLintの実行速度が気になっている方 - TypeScript 7に上げたいが、`typescript-eslint`が対応しておらず困っている方 ## Oxlintの概要・特徴 ### 概要 Oxlintは[Oxc](https://oxc.rs/)プロジェクトに含まれるRust製のLinterで、ESLintの代替を狙うツールです。 2025年6月に1.0がGAとなり、ShopifyやAirbnb、Mercedes-Benzなどの大規模プロジェクトでの採用実績があります✨ Airbnbでは126,000ファイル超の解析がCI上で7秒、Mercedes-BenzではESLintからの乗り換えでlint時間が71%削減されたと報告されています🔥 ### ESLintルールのカバレッジ OxlintはESLintおよび主要プラグインのルールをRustで再実装しています。 組み込みで提供されている主なルールセットは下記です🙌 - `eslint`本体 - `typescript-eslint` - `eslint-plugin-react`と`eslint-plugin-react-hooks` - `@next/eslint-plugin-next` - `eslint-plugin-unicorn` 設定ファイルは`.oxlintrc.json`で、ESLintのJSON形式に近い書き味です。 後述の移行ツールを使えば、既存の`eslint.config.mjs`からほぼ自動で変換できます💪 Next.jsにもデフォルトで対応しているのは嬉しいですね😎 ### 型認識lintがstableに 個人的に移行の決め手になったのが、型認識lint(type-aware linting)の対応です。 `no-floating-promises`のような「型情報が無いと判定できないルール」は、長らくESLint(`typescript-eslint`)側でしか行なえませんでした。 しかし、Oxlintは2026年7月に型認識lintがstableになりました🎉 型認識ルールは`typescript-eslint`の61個中59個が実装済みです。 「速いけど型認識ルールが無いから移行できない」が過去の話になりました✨ ## TypeScript 7とOxlintの関係 最近TypeScriptのバージョンが7に上がったので、そちらについても触れていきます。 ### TypeScript 7では`typescript-eslint`が動作しない TypeScript 7はコンパイラ本体がGo製(通称tsgo)に置き換わった世代です。 爆速になった一方で、従来のJavaScript製コンパイラAPIが廃止されました(新APIは7.1で提供予定)。 `typescript-eslint`はこのJavaScript APIを使って型情報を取得しているため、TypeScript 7では動きません。 実際、`typescript-eslint`のpeerDependenciesは`>=4.8.4 <6.1.0`です。 そのため、`typescript@7`とはインストール自体ができません😨 TypeScript 7対応のissueは存在するものの、ESLint側の制約もあり時間がかかりそうな状況です。 ### 登場人物の整理 このあたりは用語が似ていて混乱しやすいので、一度整理しておきます📝 | 名前 | 正体 | | ------------------- | ----------------------------------------------------------------------- | | TypeScript 7 | Go製コンパイラが採用された新しいTypeScript本体 | | tsgo | TypeScript 7のGo製コンパイラの通称 | | `typescript-eslint` | ESLint向けのTypeScriptプラグイン(型認識に従来のJS製コンパイラAPIを使う) | | tsgolint | tsgoをベースにしたOxc製の型認識lintエンジン | | `oxlint-tsgolint` | tsgolintをOxlintから使うためのnpmパッケージ | ### Oxlintの対応状況 Oxlintの型認識lint(`oxlint-tsgolint`)は、tsgoベースのバイナリを自前で同梱しており、**プロジェクトにインストールされたTypeScriptパッケージを一切参照しません**。 実際、本ブログでもTypeScript 6のままOxlintへ移行し、型認識lintが動くことを確認してからTypeScript 7に上げています。 つまり、プロジェクトにインストールされているTypeScriptが5系・6系のままでも、型認識lintを利用できます。 「`typescript-eslint`がTypeScript 7に対応するまで待つ」必要が、そもそも無くなるわけです😎 ただし1つ注意点があります。 同梱エンジンはTypeScript 7のセマンティクスで動作するため、ソースと`tsconfig`にはTypeScript 7互換性が必要です。 たとえば`baseUrl`のようにTypeScript 7で廃止されたオプションを使っている場合は、先にtsconfig側の移行が必要です。 {/* prettier-ignore */} `oxlint-tsgolint`のバージョン表記は同梱チェッカーの世代を表すものです。たとえば`7.0.2001`は「TypeScript 7.0.2ベース + パッチ001」の意味で、 プロジェクト側にTypeScript 7を要求するものではありません。 LinterとTypeScriptのバージョンが完全に切り離されるのは嬉しくもありますが、上手いことバージョンを合わせて使っていく必要があるかもしれないですね🤔 ## 移行方法 Oxlintへの移行方法は、Oxfmtの時と同様に大きく2通りあります。 ### 方法A: Claude Skillを使う `oxc-project`が公式のClaude Skill`migrate-oxlint`を公開しています。 ```bash title="ターミナル" npx skills add https://github.com/oxc-project/oxc --skill migrate-oxlint ``` スキル追加後にClaude Code等で`/migrate-oxlint`を実行すると、移行作業を対話的に進めてくれます。 使用感は前回のOxfmt移行記事で紹介した`migrate-oxfmt`とほぼ同じなので、詳細はそちらをご覧ください🙌 ### 方法B: @oxlint/migrateで移行する 公式の移行ツール`@oxlint/migrate`を使うと、既存のESLint設定から`.oxlintrc.json`を自動生成できます。 ```bash title="ターミナル" npx @oxlint/migrate eslint.config.mjs --type-aware --details ``` `--type-aware`を付けると型認識ルールを含めて変換され、`--details`で移行できなかったルールとその理由が一覧表示されます。 今回はこちらの方法で移行した実例を紹介していきます💪 ## 実際に移行してみた 本ブログ([TOKOS Tech Blog](https://blog.to-ko-s.com/))のリポジトリで実際に移行した手順を紹介します。 ### 移行前の構成 本ブログのESLint構成は、`eslint.configs.all`をベースにルールをてんこ盛りにした構成でした。 `typescript-eslint`の`strictTypeChecked`や`stylisticTypeChecked`に加え、`eslint-plugin-react`のall設定まで重ねています。 個別調整したルールは約90個あり、正直移行はやや大変かなと想像していました🥶 ### 移行ツールの実行 `@oxlint/migrate`に`eslint.config.mjs`を渡すと、359ルール中314ルール(約87%)が自動で`.oxlintrc.json`に変換されました✨ `files`ベースのoverrides(`scripts/`ディレクトリ専用の緩和設定)まで正しく変換されたのは驚きでした。 特に、Next.js固有のルールは`@next/eslint-plugin-next`の21ルールすべてがOxlint側に存在し、完全に移行できていました🎉 自動変換後に手を入れたのは、主に下記の3点です。 1. `settings.react.version`の明示(Oxlintは`"detect"`非対応のため`"19.2.8"`を指定) 2. `oxlint-tsgolint`のインストールと`"typeAware": true`の確認 3. `package.json`の`lint`スクリプトを`oxlint src scripts`に変更 `"detect"`は、`eslint-plugin-react`がインストール済みのReactバージョンを自動検出してくれる設定値です。 Oxlintは`"detect"`に未対応のため、バージョンの明示が必要でした。 ```bash title="ターミナル" npm install -D oxlint oxlint-tsgolint ``` `oxlint-tsgolint`はtsgoバイナリ同梱のため、型情報を別途生成する必要はありません。 ただしNext.jsが自動生成する型(`PageProps`等)を使っている場合、CIでは`next typegen`を先に実行しないと型解決できません。 本ブログもこの問題に該当するため、CIで`next typegen`を実行してからOxlintを回しています。 ### 移行できなかったルールとその判断 一方で、移行できなかったルールもあります。 本ブログでどう判断したかも併せてまとめます📝 | ルール | 状況 | 本ブログでの判断 | | --------------------------------------- | ----------------------------------------------- | ------------------------------------- | | `@typescript-eslint/naming-convention` | 未実装(型情報必須のため後述のjsPluginsでも不可) | コードレビューで担保する運用に切替 | | `import/order` | 実装しない方針(フォーマッターの責務と整理) | Oxfmtの`sortImports`に引き継ぎ | | `@stylistic`系の一部 | jsPlugins(alpha)経由でのみ利用可能 | alpha依存を避けて廃止 & Oxfmt側に移譲 | | `react/jsx-no-leaked-render`等の細則4件 | 未実装 | 影響が小さいため受容 | `import/order`については、`.oxfmtrc.json`に下記を追加することでOxfmt側がimport文の並び替えを担当してくれます。 ```jsonc title=".oxfmtrc.json" { // その他設定... "sortImports": true, } ``` lintルールとしての強制が無くなる分、CIには`oxfmt --check`を追加してフォーマット崩れを検知するようにしました💪 `naming-convention`については、昨今のLLMの賢さならよっぽどおかしな命名規則(ex: キャメルケースとスネークケースの混在)等は実装しないと思っています。 なので、コードレビューで担保する運用に切り替えました。 ### 誤検知への対処 移行後の初回実行で、誤検知が1件だけありました。 JSXを返さない高階関数`createCachedLoader`が、Reactコンポーネントと誤判定されて`react/function-component-definition`に引っかかるというものです。 こちらはESLintと同様の記法を使い、インラインで無効化できます。 ```typescript title="cache.ts" const isDev = process.env.NODE_ENV === "development" /** * ローダー関数をキャッシュ付きバージョンに変換する高階関数 */ export const createCachedLoader = (loader: () => T): (() => T) => { let cache: T | null = null // Reactコンポーネントではない高階関数の戻り値を誤検知するためoffに // oxlint-disable-next-line react/function-component-definition return () => { if (!isDev && cache !== null) { return cache } cache = loader() return cache } } ``` 約100ファイルの実プロジェクトを移行して誤検知がこの1件だけだったのは、正直期待以上でした✨ 移行ツールの完成度が本当に高く、体感としてはOxfmt移行と同じくらいスムーズでした😎 ## 移行後に実際にどれくらい速くなったのか 本ブログのリポジトリ(`src`と`scripts`の約100ファイル)で実測してみました🔥 計測に使用したのは`oxlint@1.79.0` + `oxlint-tsgolint@7.0.2001`で、各3回計測した中央値です。 ```bash title="ターミナル" time npx oxlint src scripts ``` | 条件 | eslint | oxlint | ESLint比 | | ---------- | ------- | -------- | ---------- | | 型認識なし | 約6.7秒 | 約0.36秒 | **約19倍** | | 型認識あり | 約6.7秒 | 約1.1秒 | **約6倍** | 型認識を有効にすると型認識なしの約3倍の時間がかかりますが、それでもESLint比では約6倍高速でした😎 「型認識lintは遅くなるから捨てる」という選択を取らずに済むのがOxlintの良さです。 `no-floating-promises`のようなバグを未然に防ぐルールを維持したまま、lint時間を大幅に短縮できます💪 CIのlintジョブも体感でかなり速くなりました。地味にPRのマージ待ち時間に効いてきます✨ ## TypeScript 7に上げてみた 本記事のメインテーマではありませんが、Oxlint移行によって`typescript-eslint`への依存が消えたので、TypeScript 7に上げてみました。 結論、`typescript`のバージョンを上げるだけで完了でした🎉 ```bash title="ターミナル" npm install -D typescript@^7.0.2 ``` TypeScript 7では`types`のデフォルトが`[]`になる等の破壊的変更があります。 ただ、Next.jsプロジェクトでは`next-env.d.ts`経由の型参照で吸収されるため、本ブログではtsconfigとコードを一切変更せずに済みました。 `tsc --noEmit`もエラーゼロで通っています✨ Next.js 16.3はTypeScript 7を公式サポートしており、`next build`はプロジェクトローカルの`tsc`コマンドを使って型チェックしてくれます🙌 {/* prettier-ignore */} TypeScript 7のnpmパッケージには`tsserver.js`が含まれないため、VSCode(Cursor)の「Use Workspace Version」は使えません。 当面はエディタ内蔵のTypeScriptをそのまま使うか、公式の「TypeScript 7」拡張機能(旧称: TypeScript Native Preview)を導入する形になります。 なお後者では、Next.jsのTypeScriptプラグインによる診断が効かなくなる点に注意です。 「Linterが理由でTypeScriptを上げられない」状態から解放されたのが嬉しかったです🥳 ## jsPlugins(alpha)について 「組み込みルールに無いプラグインはどうするの?」という疑問については、jsPluginsで解決可能です! OxlintはESLint v9互換のプラグインAPIをJavaScriptで提供しており、既存のESLintプラグインをそのまま読み込める仕組みを開発中です。 記事執筆時点(2026-08-23)ではalpha段階です。 設定は`.oxlintrc.json`の`jsPlugins`にプラグインを追加し、`rules`で使いたいルールを指定します。 組み込みプラグインと名前が衝突する場合は、下記のようにエイリアスも指定できます。 ```jsonc title=".oxlintrc.json" { "jsPlugins": [ // 組み込みに無いプラグインはそのまま指定 "@stylistic/eslint-plugin", // 組み込みの`jsdoc`と名前が衝突するためエイリアスを付ける { "name": "jsdoc-js", // ルール名のプレフィックス "specifier": "eslint-plugin-jsdoc", // 実際のnpmパッケージ名 }, ], // 使いたいルールを指定する "rules": { "@stylistic/semi": ["error", "never"], "jsdoc-js/check-alignment": "error", // プレフィックスはエイリアス名 }, } ``` ほぼ全てのパターンにおいて、`eslint-plugin-react-hooks`等の実プラグインで正常に動作することが確認されています。 ただし、下記の制約があります。 - 型情報を必要とするルールは動かない - カスタムパーサ(Vue/Svelte等)は非対応 - alpha段階のため、APIは今後変わる可能性がある 本ブログの移行では`@stylistic`の2ルールをjsPluginsに載せる選択肢もありましたが、alpha段階の機構に依存するのを避けて廃止を選びました。(+ oxfmt側のルールに寄せたかった) 安定化したらあらためて活用を検討したいところです💪 ## VSCode(Cursor)での設定 エディタ統合は、Oxfmt移行時にインストールした公式拡張機能`Oxc`がそのまま使えます。 拡張機能が`.oxlintrc.json`を読み取り、エディタ上でリアルタイムにlint結果を表示してくれます🙌 拡張機能のインストール方法や`settings.json`の設定は、前回の記事をご覧ください。 ## 補足 最後に、移行のトレードオフ及び本ブログでの方針を記載します。 - `naming-convention`による命名規則の機械強制(→ レビュー運用でカバー) - `import/order`のlintとしての強制(→ Oxfmtの`sortImports` + `oxfmt --check`でカバー) - `@stylistic`系の体裁ルールの一部(→ フォーマッターとレビューでカバー) - ESLintの豊富なサードパーティプラグイン資産(→ jsPluginsの安定化待ち) 本ブログでは、これらを許容しても「TypeScript 7へアップデート」「速度6倍」の方を取った形です。 逆に言うと、`naming-convention`やサードパーティプラグインを必須とするプロジェクトでは、もうしばらくESLint併用(`eslint-plugin-oxlint`でルール重複を回避する構成)が現実的だと思います🤔 とは言え、今後の潮流としてはOxlintの方が良さそうなので、今後はOxlintをメインに使っていきたいと思います。 プラス、他のプロジェクトもoxlint+oxfmtで運用していて問題を感じていないので、やはり移行を推奨という感想になりました💪 ## おわりに 今回は、Rust製の爆速LinterであるOxlintへの移行方法を本ブログでの実例と実測値を交えて紹介しました🎉 - 型認識lintを維持したまま約6倍高速化(型認識なしなら約19倍) - `@oxlint/migrate`で約87%のルールが自動移行、Next.jsルールは21/21で完全移行 - 型認識lintがインストール済みTypeScriptに依存しなくなり、TypeScript 7へ即移行可能に - 移行しきれないルール(`naming-convention`等)はあるが、大きな問題は無い OxfmtとOxlintが揃い、本ブログのフォーマッター・LinterはOxcスタックに完全移行しました🎉 これからもアップデート等があれば、随時記事にしていきたいと思います! --- ## 【HTML】キーボード操作で考えるアクセシビリティの基本 - URL: https://blog.to-ko-s.com/articles/keyboard-accessibility - 著者: 小田優真 - 公開日: 2026-07-27 - タグ: HTML > マウスを使わずキーボードだけでWebページを操作するという視点から、HTMLのアクセシビリティを解説します。button要素の正しい使い方、フォーカスの当たり順、フォーカスリングの扱いを、WCAGの「操作可能」原則と対応づけながら紹介します。 ## はじめに こんにちは、株式会社TOKOSの優真です。 今回は、HTMLによるWebアクセシビリティを「キーボード操作」の観点から解説します。 WebページのアクセシビリティはHTMLの書き方に左右されます。どの要素を選び、どう組み立てるかによって、操作しやすさが変わります。 普段PCをマウスで操作していると意識しないのですが、マウスを使わずキーボードだけでWebページを操作している方がいます。 そうした操作を妨げないHTMLの書き方は、特別な知識がなくても、基本を押さえるだけで実践できます。 記事内にはキーボード操作を録画した動画も載せているので、あわせて確認してみてください。 ### 対象読者 - HTMLのアクセシビリティに興味がある方 - キーボード操作で生じる問題を知りたい方 ## キーボード操作とアクセシビリティ ### アクセシビリティとは アクセシビリティとは、年齢や障害の有無にかかわらず誰もがWebページを利用できる状態を指す考え方です。 書籍『Webアプリケーションアクセシビリティ』では「利用可能な状況の幅広さ」と表現されています。 使う人や環境・状態の幅まで含むため、障害のある方や片手がふさがっている時、加齢で操作しづらくなったときなども対象に入ります。 似た言葉に「ユーザビリティ(使いやすさ)」がありますが、視点が異なります。 ユーザビリティが「どれだけ快適に使えるか」に注目する一方、アクセシビリティは「そもそも使えるのか」という、利用できる範囲そのものを広げる考え方です。キーボードで操作しやすいページは、マウスが使えない方にもたまたまマウスが手元にない方にも使いやすくなります。 その中でも「キーボード操作」は、アクセシビリティの向上に繋がる重要な観点の1つです。 ### キーボード操作が重要な理由 キーボード操作ができないとWebページ自体を使いこなせない人がいます。たとえば、次のような方です。 - 手の動きに制約があってマウスの細かい操作が難しい方 - 視覚に制約があって画面を直接見ることが難しい方 手の動きに制約がある方は、マウスの代わりに`Tab`キーや矢印キーでフォーカスを動かします。`Enter`キーや`Space`キーで実行するなど、キーボードだけで画面内を移動します。 視覚に制約がある方は、スクリーンリーダー(画面の内容を音声で読み上げるソフト)とキーボードの専用ショートカット(見出しやリンクへのジャンプなど)を組み合わせて、ページ内の要素をたどりながら操作します。 キーボードだけで操作する方にとって、操作できないボタンやリンクは「存在しないもの」と同じです。 自分で作ったWebが、誰かにとっては使えないものになっているのは悲しいですよね🥲 ### WCAGの「操作可能」という原則 アクセシビリティには、WCAG(Web Content Accessibility Guidelines)という国際的なガイドラインがあります。 このWCAGは「知覚可能」「操作可能」「理解可能」「堅牢」という4つの原則で構成されています。 このうちキーボード操作と深く関わるのが、**操作可能(Operable)**の原則です。 これは、ユーザーインターフェイスのコンポーネントやナビゲーションが操作できる状態でなければならない、という考え方です。 「操作可能」はさらに細かい達成基準に分かれていますが、本記事で扱うポイントは、次の基準と対応しています。 | 本記事で扱うポイント | 対応するWCAGの達成基準 | | ------------------------------------ | ------------------------ | | キーボードだけで操作できるようにする | 2.1.1 キーボード | | フォーカスの順序を整える | 2.4.3 フォーカス順序 | | フォーカスリングを消さない | 2.4.7 フォーカスの可視化 | 各ポイントの解説のなかで、対応する基準もあわせて紹介していきます。 ### キーボード操作の基本 キーボードだけでWebページを操作するとき、主に次のキーを使います。 #### `Tab`キー・`Shift+Tab`キーでフォーカスを移動する `Tab`キーを押すと、リンクやボタン、入力欄など操作できる要素へ順番にフォーカスが移っていきます。 フォーカスとは「いまどの要素を選んでいるか」を示す状態のことです。 | キー | 動作 | | ------------- | ---------------------------- | | `Tab` | 次の操作可能な要素へ移動する | | `Shift + Tab` | 前の操作可能な要素へ戻る | 下記の動画では、`Tab`キーを押すたびにリンクへ順番にフォーカスが移り、フォーカスの当たった要素に枠線(フォーカスリング)が表示されています。 画面下の「フォーカス状態」には、いまどの要素にフォーカスがあるかが表示されています。