@amritk/validation

中文文档
0.6.1发布于 2026/10/23MITTypeScriptES Module构建来源已验证近 30 天下载 5.9万

One generator surface over mjst's types, validators, coercers, repairers and parsers.

原始使用文档
文档目录11 节
  1. Overview
  2. Installation
  3. Usage
  4. The modes
  5. Moving off Ajv
  6. API
  7. generate(rootSchema, rootTypeName, options?)
  8. Output layout
  9. Is it faster?
  10. Related packages
  11. License

@amritk/validation

One generator surface over mjst's types, guards, validators, coercers, repairers and parsers.


Overview

@amritk/validation is the front door. Given a JSON Schema (Draft 2020-12) it emits one coherent set of TypeScript files carrying whichever runtime entry points you ask for — from a bare type, through a boolean guard and a full error report, to a coercer, a repairer and a total parser.

Underneath it are two generator engines, not one: a validator, coercer and repairer engine, and a parser and type engine. They shipped separately as @amritk/generate-validators and @amritk/generate-parsers until this package absorbed them, and keeping both is deliberate. They emit genuinely different code for the value-producing modes: a parser fuses building the output with checking it, and is several times faster for it; a validator keeps the passes apart, and can therefore report what was wrong. Collapsing them into one emitter would mean giving up one of those properties.

What makes one output directory possible is that both derive the TypeScript type from the same @amritk/helpers/generate-type-definition, so the type is declared once and every function in the output is talking about it.


Installation

bun add @amritk/validation

Usage

import { generate } from '@amritk/validation'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'

const schema: JSONSchema = {
  type: 'object',
  properties: {
    retries: { type: 'integer', minimum: 0, maximum: 10, default: 3 },
    name: { type: 'string', minLength: 3 },
  },
  required: ['retries', 'name'],
}

const files = await generate(schema, 'Config', {
  modes: ['types', 'guard', 'validate', 'coerce', 'repair', 'parse'],
})

for (const file of files) await Bun.write(`./generated/${file.filename}`, file.content)

The modes

Each mode is a different answer to how much may this do to my document, and what do I get told. They compose: ask for several and you get several functions over one type, and every one that judges a value judges it with the same validateX, so no two can disagree about whether a document is valid.

modefunctionvalue backtells youstops at
types————
guardisX(input): input is X—a booleanthe first problem
validatevalidateX(input)—every errorthe end
checkcheckX(input)—the first errorthe first problem
coercecoerceX(input)coercedevery errorthe end
repairrepairX(input)repairedrepairs and errorsthe end
parseparseX(input)repairednothingnever fails
parseStrictparseX(input)as giventhrows the firstthe first problem

Validate only, fail fast or find every error. guard is the fail-fast half: it stops at the first thing wrong and builds no error object, which on invalid input is dramatically cheaper than reporting. validate is the other half: every error, each with a JSON Pointer, the keyword that rejected the value, and that keyword's own values. Reach for guard when the answer is a branch and validate when a human or an API client has to be told what to fix. check sits between them: checkX returns the same result type as validateX, holding only the error validateX would have reported first, and walks no further.

Coerce and repair, with or without the errors. coerceX and repairX hand back the value and the diagnostics; parseX hands back only the value and never fails. Coercion moves a value that is already right but written in the wrong type; repair also substitutes a value the schema supplies for one that cannot be coerced, and reports the validator's own errors as the repairs.

That makes repairX an autofix tool rather than a stricter validator. It accepts documents validateX rejects (a missing required name gets one; an unknown enum value becomes the first member), and accepting only valid with an empty repairs gives exactly the verdicts coerceX gives. An empty repairs alone is not enough: a document nothing could repair comes back with none either, and valid: false. To reject a bad config, use coerceX. To hand back a fixed one, use repairX.

Repairing large documents. repairX(input, options?) takes an options object (a bare string is still read as the base path):

optiondefaultwhat it does
path''JSON Pointer every reported path is rooted at
inPlacefalseassign and delete on the input instead of copying the containers a repair touches
coercetruemove a scalar written in the wrong type ("5" under integer) before repairing; off, it is repaired to the schema's value and reported
refstruefollow instance $refs at positions whose Reference schema opts in (below)
skipRefs[]pointer prefixes ('#/components/') whose targets the root schema already walks; still resolved, not walked again
onRef—(value, path, typeId) => boolean | void, called for every opted-in $ref; false leaves it alone
maxDepth512depth past which a position is reported (maxDepth) instead of walked
maxWork10_000_000containers visited before the walk stops (maxWork)
memofalsewalk each object once per schema — for in-memory graphs with shared or cyclic subtrees
finitefalsereject NaN and the infinities where a number is declared
plainObjectsfalseaccept only plain objects, and remove an own __proto__ key

When every definition in the schema is built from type, enum, const, properties, required, additionalProperties, patternProperties, items, $ref, if/then/else and allOf — plus annotations, and scalar assertions such as minLength, pattern, minimum, anyOf, oneOf or not, which are asked of the validator's own test of the position — repairX is a single pass: each position is checked and repaired where it stands, an if is evaluated against the value actually there, a missing required object is built with its required children (to a bounded depth on a recursive definition), and errors go straight into one array with their paths built only when something is reported. A valid document comes back as itself with nothing copied. On a 24 MB bundled OpenAPI document that is well under a hundred milliseconds (bun --conditions development scripts/bench-repair-walk.ts). Any other keyword keeps the validate-and-repair loop, which honours path, inPlace and coerce and throws on the walk-only options (skipRefs, onRef, maxDepth, maxWork, memo, finite, plainObjects) rather than quietly dropping a guard.

Two schema annotations steer it. "x-repair": "drop" — and a false schema — removes a failing position instead of writing a default: an unknown key under additionalProperties: false, an array element under items: false, a drop-marked property. "x-repair": "ref" on a Reference schema turns on instance $ref following: at a position written { if: { type: object, required: [$ref] }, then: Reference, else: T }, the $ref is resolved as a JSON Pointer against the root document (own properties only), and its target is checked and repaired as that position's type, with errors at the target's own path. Each target is walked once per type, chains and cycles included; one that does not resolve is reported as unresolvedRef and never created.

parseX agrees with coerceX. Wherever coerceX accepts a document, the coercing parseX returns the very same value — through a union, an allOf, an if, a $ref, recursion included — and it only repairs what coerceX would have rejected. A differential test pins that over random schemas. To get there, a definition whose own tree has an anyOf/oneOf/allOf/if/not carries an exact test (matchesX) and the coercion walk (coerceXInput) in front of its repairing parser, and every definition such a one reaches carries them too. A schema without combinators emits exactly what it always did; one with them emits more — about twice the parser code on a large OpenAPI document — which is what a second, exact opinion on each definition costs. A strict or stripUnknown parser has no coerceX to agree with and is unchanged.

parse and parseStrict are the same function name under two contracts, so asking for both is an error rather than a silent choice.

Moving off Ajv

coerceX is built to replace Ajv compiled with { allErrors: true, coerceTypes: true }, and it is better on each count: the answer, the speed and the safety of the input.

It accepts only what the schema accepts. Anything coerceX hands back as valid, validateX has just accepted, and a differential fuzz over random schemas with anyOf, oneOf, allOf and if/then/else checks every accepted document against an Ajv that does not coerce. Where the two differ, it is on purpose:

  • null is never coerced, in either direction, and it is the difference you will meet most: on a real config corpus it was nearly every case where Ajv accepted and coerceX did not. Ajv reads null as "", 0 or false for a string, number or boolean field, and reads those back as null. A null usually means "not set", and turning it into a real value erases that. Here it goes to the validator as written, and the validator rejects it.

    Before switching, check what your callers do with a rejected document. If one treats rejection as absence (returns undefined, falls back to a default), a single null deep inside a block makes the whole block vanish without an error. Ajv's coercion used to hide that. Either let the schema say null is allowed (type: ['string', 'null']), drop nulls before validating, or have the caller surface the errors instead of discarding the value.

  • Only clean numerals become numbers. " ", " 1 ", "0x10", "Infinity" and "1." are rejected. Ajv turns them into 0, 1, 16, a value JSON cannot hold, and 1. "007" and "1e3" are still coerced.

  • At a union, a branch the value already matches wins. Ajv coerces into the first branch that will take the value. Given anyOf: [{ type: 'string' }, { const: false }], Ajv turns false into "false"; coerceX keeps false. When no branch matches as written, each branch coerces the value its own way and the result is taken only if every branch that then accepts it agrees. Two different readings leave the value alone for the validator to report, so the order the branches were written in never changes the answer.

  • A branch that fails leaves nothing behind. Ajv coerces in place while it tries each branch, so under oneOf: [{ type: 'integer' }, { type: 'string' }] it turns 0 into "0" while trying the string branch, sees two matches, and rejects a valid document. coerceX accepts 0 as written.

  • Coercion reaches every applicator. Scalars are coerced through $ref, allOf (each subschema in turn), if/then/else (toward then when the value matches if, else otherwise), and union branches, nested to any depth.

It never touches the input. Ajv coerces in place, so a caller that keeps its document has to clone it first. coerceX returns a new value that shares everything it did not touch, or the input itself when nothing needed coercing.

It is faster where the input has to survive. Against Ajv cloning first, so both leave the caller's document alone (bun run bench:validators:coerce, or bench:validators:coerce:node):

schemaruntimevalid inputneeds coercingcannot be coerced
small (4 fields)Bun5.4×1.7×1.5×
small (4 fields)Node9.9×2.1×2.2×
order (nested + array)Bun11×1.6×1.9×
order (nested + array)Node8.4×1.8×2.2×
assert-looseBun19×1.4×3.0×
assert-looseNode36×1.6×3.1×
config (unions via $ref)Bun7.2×1.7×3.7×
config (unions via $ref)Node5.2×1.5×4.6×

Each cell is how many times faster coerceX is than Ajv on the same input, each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A valid document is the common case and the widest gap: coerceX answers it with isX and hands the input straight back, without walking it. isX builds nothing — no error, no path — and stops at the first failure, answering a $ref with the target's own isX; it calls validateX only for a schema that folds to a constant. On valid input Ajv rewrites nothing, so it can also be timed without the clone; coerceX is still ahead there on these schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The gap above is the clone, so it is real exactly where the caller cannot let Ajv mutate its input. The config case is the shape --coerce exists for: every coercible scalar sits inside a union reached through $ref, and every union branch is tested by a named function rather than a closure built per call.

The errors differ in one way worth knowing if you key diagnostics on path + keyword + params: an additionalProperties error points at the undeclared key (/readme/example_requests) where Ajv points at the object holding it (/readme). params.additionalProperty names the key in both.


API

generate(rootSchema, rootTypeName, options?)

ParameterTypeDefaultDescription
rootSchemaJSONSchema—The root schema. $ref and $dynamicRef are resolved recursively.
rootTypeNamestring—Name for the root type (e.g. "Config").
options.modesMode[]['types', 'guard', 'validate']Which entry points to emit. The default is read-only; anything that rewrites a document is opt-in.
options.typeSuffixstring''Suffix on every $ref-derived type name. The root name is untouched.
options.schemasRecord<string, unknown>—Documents you have already loaded, keyed by the URI a $ref names them by.
options.unknownKeys'count-keys' | 'count-enumerable''count-keys'How a closed object's fast path proves it carries no undeclared key.
options.formats'all' | string[]—String formats the validators enforce. Unset leaves format an annotation.
options.branchErrorsbooleanfalseExplain a failing anyOf / oneOf with the branch it plainly meant, or with the kinds it accepts when no branch takes the value's kind.
options.stripUnknownbooleanfalseBuild each parsed result from the declared properties only.
options.readonlybooleanfalseEmit readonly type members.
options.caseInsensitivebooleanfalseNormalize a mis-cased string onto an enum member it matches.
options.helpersMode'package' | 'embedded''package'Where the parser half gets its runtime helpers.
options.helpersImportPrefixstring'./'Prefix on emitted-helper import specifiers, when helpersMode is 'embedded'.
options.extensionsSchemaExtensions—Schema extensions, passed through to the parser half.
options.importExt'js' | 'ts''js'Extension on every emitted relative import. 'ts' emits the on-disk paths, so the output runs under Node's type stripping with no build step.
options.logWarningsbooleanfalsePrint the parser half's warnings about the schema, such as a keyword it cannot enforce.

Returns: Promise<GeneratedFile[]> where GeneratedFile = { filename: string; content: string }.

The package also exports ALL_MODES (every mode but parseStrict, for a caller who wants the lot) and the types Mode, GenerateOptions, GeneratedFile and ImportExtension.


Output layout

config.ts         the type, plus isX / validateX / coerceX / repairX
config.parse.ts   parseX, importing the type from ./config.js
validation-result.ts   the shared error and result types, and the runtime helpers
formats.ts        the format checks, only when `formats` enforces one the schema names
_helpers/         the parser's runtime helpers, only when `helpersMode` is 'embedded'
index.ts          a barrel over all of it

The type lives in config.ts because that is where a reader looks for it. The parser half moves to config.parse.ts because both generators name their file after the schema and one of them has to give way. There is exactly one export type Config in the output, whatever combination of modes you asked for — which the test suite asserts, over a corpus of schema shapes, by compiling the result under this repo's own flags.

Ask for no validator mode and there is nothing to collide with, so the parser keeps both its own filename and the type it already authored, and no validation-result.ts is emitted at all — a parse-only build has nothing that could import its 17 KiB of error types and runtime helpers.


Is it faster?

Than the two engines it wraps? No, and it should not be. (Than Ajv, yes — see Moving off Ajv.) Ask this package for one mode and it emits the exact bytes the engine that owns that mode emits — which the test suite pins per mode, by fingerprint, not by reading the output. Identical code cannot run at a different speed, so there is no runtime claim to make here and none is made. bun run bench measures it anyway, and the deltas wander either side of zero between runs, which is what process-level variance looks like when there is no underlying difference.

What does change is the cold side, and only in the direction you would hope:

before (both engines directly)after
codegen for the whole matrix2.9 ms2.7 ms
emitted bytes46.0 KiB45.8 KiB
files76
declarations of Order21
per-mode codegen—equal or slightly lower
per-mode bytes—identical

The one that matters is the last row of the middle block. Reaching every mode used to mean running both generators yourself and shipping two trees for one schema, including two declarations of the same type — structurally interchangeable, but two things to keep in step. Now there is one.



License

MIT

版本与发布记录

@amritk/validation 共发布 9 个版本,首次发布于 2026/09/22;过去 12 个月发布了 9 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.6.1latest2026/10/2314 天前
0.6.02026/10/2116 天前
0.5.02026/10/1027 天前
0.4.12026/10/0730 天前
0.4.02026/09/261 个月前
0.3.22026/09/241 个月前

关于 @amritk/validation 的常见问题

@amritk/validation 是什么?

One generator surface over mjst's types, validators, coercers, repairers and parsers.

如何安装 @amritk/validation?

在项目目录运行 npm install @amritk/validation,或使用 pnpm add / yarn add / bun add。

@amritk/validation 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@amritk/validation 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@amritk/validation 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@amritk/validation 可以商用吗?

@amritk/validation 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@amritk/validation 的下载量有多少?

根据 npm 官方下载统计,@amritk/validation 在 2026/10/06 – 2026/11/04 这 30 天内被下载 59,097 次,日均约 1,970 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@amritk/validation 还在维护吗?

最新版本 0.6.1 发布于 2026/10/23(14 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/09/22。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。