Repository navigation
featherduster - #1685
featherduster#1685ssalbdivad wants to merge 2634 commits into
Conversation
060798f to
776daf2
Compare
|
@pullfrog review this |
There was a problem hiding this comment.
Important
Two reproducible crashes in the new copy-on-write helper copyOf (ark/schema/shared/transform.ts): it throws for every RegExp, and for any array carrying an own slice property. Both are reachable through the public API and contradict the "never writes to the input" contract this PR introduces. Details inline.
Reviewed changes
ArkErrorsno longer extendsArray— errors now live in a plainissuesarray with lazily builtbyPath/byAncestorPathcaches; iteration,length,summaryand Standard Schema support are preserved, and the new merge/countlogic tracks 2.2.7's traversal-stop semantics.- Copy-on-write
Transformreplacesclone— newcopyOf/mergeTransformedhelpers plusTransformErrorsinark/schema/shared/transform.ts; this is the source of both findings below. - New
arksetspackage — the set algebra is extracted from@ark/schemainto a separately installed engine (setEngine). Without it,@ark/schemaparses unreduced nodes and throwsmissingSetEngineMessageon relations/JSON Schema;arktypeimports it. I traced the extracted intersection loop (intersectConstraints) against its prior form and found no dropped-constraint or non-termination defect. - Public API/behavior updates —
configure()must now run before any package import;"+": "delete"removes undeclared symbol keys; root-morph failures throw from.assert; dates describe in UTC.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| Error: () => new Error(), | ||
| Headers: (data: Headers) => new Headers(data), | ||
| Map: (data: Map<unknown, unknown>) => new Map(data), | ||
| RegExp: (data: RegExp) => new RegExp(data), |
There was a problem hiding this comment.
copyContentsOf.RegExp builds its copy with new RegExp(data), which always owns a non-configurable lastIndex. The descriptor overlay at lines 82-88 then redefines that property with configurable = true and throws TypeError: Cannot redefine property: lastIndex for every RegExp. This is reachable through the public API — e.g. type({ x: "string.trim" })(regexpWithOwnProp) (a RegExp with any own structure-transformed prop) routes through StructureNode.copy → copyOf.
Technical details
# copyOf throws on every RegExp
## Affected sites
- `ark/schema/shared/transform.ts:127` — `copyContentsOf.RegExp` produces a copy that already owns non-configurable `lastIndex` (and does not carry the source's `lastIndex`).
- `ark/schema/shared/transform.ts:82-88` — the overlay copies `Object.getOwnPropertyDescriptors(data)` (including `lastIndex`) onto the copy after forcing `configurable = true`; redefining the copy's non-configurable `lastIndex` throws.
## Evidence
`copyOf(/a/g)` throws `Cannot redefine property: lastIndex`; `type({ x: "string.trim" })` invoked on a RegExp with an own prop throws the same.
## Required outcome
Copying a RegExp must not throw and must preserve `lastIndex` plus any own enumerable props. Assigning `copy.lastIndex = data.lastIndex` (the property is writable) or otherwise avoiding the non-configurable descriptor collision would satisfy this. Typed arrays already short-circuit at line 80 for the same class of reason; RegExp needs equivalent handling.
## Open questions
- Are there other `copyContentsOf` kinds whose copy owns a non-configurable property the overlay would redefine? `lastIndex` appears unique to RegExp.| } | ||
| let copy: unknown[] | ||
| // slice builds its copy from data.constructor, which a named key can shadow | ||
| if (data.constructor === Array) copy = (data as unknown[]).slice() |
There was a problem hiding this comment.
This branch deliberately supports arrays with named keys (they are copied at lines 63-64), but it calls data.slice() through the instance, so an array that owns a slice property throws TypeError: data.slice is not a function. Reachable via e.g. type(["string.trim"])(arr) where arr.slice is set, since StructureNode.copy/SequenceNode call copyOf on the array.
The constructor shadowing is guarded on this very line; the call should use Array.prototype.slice.call(data) for the same reason.
| if (data.constructor === Array) copy = (data as unknown[]).slice() | |
| if (data.constructor === Array) copy = Array.prototype.slice.call(data) |
Two crashes in copyOf, both reported on #1685 and reproduced through the public API: - A RegExp's copy is `new RegExp(data)`, which already owns a non-configurable `lastIndex`. The descriptor overlay forced every descriptor to `configurable: true`, so redefining `lastIndex` threw "Cannot redefine property: lastIndex" for any RegExp with a transformed prop. A descriptor now stays non-configurable where the copy already owns that key non-configurably; `lastIndex` is writable, so the redefine succeeds and carries the input's value. - An array copied with `data.slice()` threw when the array owned a `slice` key. It now calls `Array.prototype.slice` directly, as the `constructor` guard beside it already anticipates a shadowing key. Both tests fail without the fix, jit and jitless. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An object ArkType builds as output, like a "+": "delete" copy, data with
defaults filled in or a copy holding transformed values, now lists its
declared keys in the order the definition declares them, rather than
required keys, then optional keys, each sorted. Zod returns the same
order, and it's the shape data is usually serialized in, so a copy usually
shares its input's hidden class.
A structure's declared order is a new inner key, `order`, which object
literal parsing writes and arksets drops where it lists the props as
they're sorted. It's written to no json (its key is `unserialized`), so
json, hashes, `expression` and JSON Schema stay canonical, and types
declaring keys in different orders are still equal, nested too, which a
json key couldn't give, since a parent's json holds its children's. It's
part of a node's cache key, so `{ a, b }` and `{ b, a }` are distinct
nodes, and where something keyed by hash would conflate them the key now
holds the order: precompiled units, the structural morph structures share,
and the operand an intersection returns when its result equals it.
A spread, `.merge()` or `.and()` lists the left object's keys, then the
right's new ones, as `{ ...l, ...r }` would. `.pick()`, `.omit()`,
`.partial()`, `.required()` and a union's merged props keep their source's
order, a JSON Schema's are as `properties` lists them, and a schema with
no order falls back to the sorted one. Where undeclared keys are kept, a
copy keeps the input's order, as writing in place would, and missing
defaults follow it in declared order. A delete copy's literal holds the
required keys its order starts with and stores the rest after it
(`resultLayout`, which precompiled copy primers can read).
Measured against 658c183 (instructions counted with perf):
- copying, per call (bench/count.ts, median of 5, arktype and standalone
alike): strip moltar 178 -> 157, as its data arrives in declared order;
strip product 9.9k, parse items defaults 39.0k and parse moltar morph
730 unchanged
- creating the moltar type with "+": "delete", per type: 989k -> 1039k
amortized over collections (1.051, 0 of 7 runs lower), 647k -> 689k in a
warm window (1.065, 0 of 5). The rest is nodeKeyOf writing the order and
collecting the arrays retained per structure; a first version that
resolved the order eagerly with a Map cost 1.13.
- `export { type } from "arktype"`, minified: 254061 -> 255517 B, gzipped
81031 -> 81554 B
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # ark/aot/__tests__/declare.test.ts # ark/aot/cli.ts # ark/aot/driver.ts
A Standard Schema consumer prints an issue's path before its message (the MCP
SDK, form libraries and Zod's own issues all do), but ~standard.validate
returned the ArkErrors, whose errors' messages already begin with their
path, so a migration from Zod printed "entities: entities must be an array
(was missing)".
validate now returns { issues }, each error at a path copied with its
message written as if the data at its path were validated alone: the error
and a union's or intersection's children are written with the error's path
removed from theirs, so a union lists "b ... or c ..." rather than
"a.b ... or a.c ...". ArkErrors' own errors and summary are unchanged. A
precompiled validator's ~standard does the same from each issue's problem,
keeping a message it was configured with, which its build already wrote.
An embedded Standard Schema's issues now arrive without their path whatever
validator wrote them, so parsing one no longer special-cases arktype's
ArkErrors, and messageAt, added for that this release, is replaced by the
internal messageFromPath. aot's shadow harness compares a Standard failure
as errors holding its issues, returning the program the result itself.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
type({}), type("object") and type({ "+": "reject" }) were written as
{ type: "object" }, with additionalProperties: false for the last. MCP clients
read a tool's input schema's properties to list its arguments, and Zod writes
z.object({}) as { type: "object", properties: {} }, so a tool migrated from
Zod with no arguments had a schema its clients rejected.
The object domain is now written with an empty properties, which a
structure's props replace as before. A structure with an index signature but
no props drops it, since its keys are written as patternProperties or
additionalProperties (and an empty-pattern index needs no properties to
become additionalProperties). Every target allows an empty properties.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…chema
type({ "a?": "string | undefined" }).toJsonSchema() threw "Can't convert
\"unit\" at a", and ~standard.jsonSchema, whose fallbacks write what JSON
Schema can't hold as {}, wrote a: { anyOf: [{ type: "string" }, {}] }, so
any value passed. Zod migrations hit it on every .optional() field written
as "string | undefined".
JSON can't hold undefined, and an optional key not listed in required
already allows its absence, which is the only way JSON can leave it
undefined. So an optional prop's value is written without its undefined
branches: { type: "string" }, or { enum: [] } for "a?": "undefined", whose
key can then only be absent. A required key's undefined still needs
fallback.unit, since there the key must be present.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…configured
With configure({ onUndeclaredKey: "delete" }), C.or({ "+": "ignore", uses:
"string", "o?": C }) was built and returned { provider, uses, x } unchanged,
though both branches allow it and C's would delete x. A structure's
applyConfig dropped an explicit "ignore" once it had kept the config's
"delete" off, and gave any structure without undeclared the config's, so
every structure built from one ignoring undeclared keys took "delete" back:
its .in once it held a morph (which made the branches look disjoint here),
intersections, merge, partial, and .onUndeclaredKey("ignore") itself.
"ignore" now stays in a structure's inner wherever the scope configures
another behavior, so whatever is built from that inner keeps it, and is
still dropped where "ignore" is the config's own. Code asking whether
undeclared keys are constrained reads the new handlesUndeclared, which is
undefined for "ignore"; an intersection of an ignoring structure with one
not constraining them stays "ignore", and .in of a deleting structure keeps
its explicit "ignore" rather than having it stripped and reapplied. Under
such a config an ignoring object prints "+ (undeclared): ignore".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Standard Schema spec makes any result with issues a failure, but a
vendor returning { issues: [] } added no error, so the morph returned
undefined and validation passed with { a: undefined } as output. An empty
issues now adds one "is invalid" error at the embedding key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TS rejects "number < 5 < 6" and "number != 3 == 4" with "must have at most one right bound", but the runtime parser applied each bound to the root the last one produced, so the first parsed as number < 5 (the < 6 silently intersected away) and the second as the unit 4. With !=, every chained form a JS or runtime-only definition could write was a new silent reading. The state now remembers the root a right bound produced and the operand it bounded until the next operand is parsed, and a right bound applied to that root throws the TS message naming the operand. A group's close forgets it, since a printed range like 0 < (number != 5) < 10 must parse again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
describeCollapsibleDate wrote a date at midnight on January 1 as its year
alone, which reads as the whole year: Date <= d'2020-01-01' reported "must be
2020 or earlier (was June 1, 2020)", contradicting itself, and a bound one
millisecond earlier printed its data as "(was 2020)". 2.2.x only collapsed
at local midnight; describing dates in UTC made every date-only literal
collapse. A midnight date is now written as its calendar date, and de's
date writer does the same ("der 1. Januar 2020"), so its theDate no longer
special-cases a bare year.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A unit miss printed its data by kind, so type("d'2020-01-01'") rejecting
January 2 reported "(was an object)", where 2.2.x printed the date and the
bound and != errors on Dates still do. Kind-only printing keeps an object's
contents out of messages, but a Date's only content is its instant. The unit
actual now writes a valid Date as describeCollapsibleDate does (de as its
dated noun), which also prints the data of a union of Date units, and keeps
the kind for an invalid Date or anything else.
The eager-issues lane records a Date's time at the failed check
(dateOf(data, error)); once it lands, writeUnitActual and de's unit actual
should read the recorded date like the other Date writers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GlobalOnlyConfigOptionName keeps onUndeclaredKey, numberAllowsNaN, numberAllowsInfinity, dateAllowsInvalid, keywords and locale out of a Scope's config type, but a JS or cast config got divergent behavior: a Scope's onUndeclaredKey was applied to its structures by applyConfig, while its numberAllowsInfinity and locale were silently ignored. Honoring all of them would mean per-scope intrinsics and locales; the type already says they're global, so the Scope constructor now throws a ParseError naming the option and configure() for any of them, as the type does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…se does string.date.iso.parse reads an offsetless ISO string in UTC and rejects a date that doesn't exist, but string.date.parse, the keyword most users reach for, still used new Date(s): "2020-01-01T12:00" was 12:00Z on a UTC host, 17:00Z in New York and 06:30Z in Kolkata, and "2025-02-30" rolled over to March 2, though the same text as a literal or through iso.parse is rejected. string.date.parse and string.date's check now read any string iso8601Matcher accepts through isoDateOf, and fall back to new Date(s) only for other forms like "5/21/1993", which have no standard reading. ISO week and ordinal dates, which new Date can't read, now parse too. iso8601Matcher moves to functions.ts so the morph can read it without importing string.ts; string.ts re-exports it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
.merge() takes object-level settings from the merged-in object ("+" among
them), but dropped "@" from both operands, though it type-checks in the
operand: A.merge({ "@": "B", b: "number" }) was described by its props,
while { "...": A, "@": "B", b: "number" } is "B". Each merged branch is now
configured with the merged-in branch's meta, so merge and spread agree; a
base's meta is dropped by both, since the result is a new object.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a Date Date.prototype's methods throw on an object without a [[DateValue]] slot, so Object.create(Date.prototype) passed `instanceof Date` and then made every Date check throw "this is not a Date object": the proto check's isValidDate, a Date unit's getTime, and the proto and unit `actual` writers. A Date | InvalidDate basis accepted it outright. 2.2.9 behaves the same; a validator must not throw on input. timeOf reads a Date's time through a try/catch, returning undefined for such an object. isValidDate repeats its body rather than calling it: the call and undefined check measurably slowed validating a Date (parse product, which holds seven, went from 6.05k to 6.2k instructions per call; inlined it stays at 6.05k). A Date unit and a Date | InvalidDate basis compare through timeOf, exported from arktype/runtime so a precompiled validator references it. Bounds and != need nothing, since they run only after the Date basis passes. The writers describe the object as "an object" rather than "an invalid Date" or "Date". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ther failures are The Apply of data in a discriminated union's default case is only written differently by standalone builds, so it's a hook of their compiler, as the union's other failures are, rather than a method dynamic builds carry. Standalone output is byte-identical (shop, login and moltar, default build). Dynamic bundle from source: 253507/80870 -> 253274/80804 min/gz. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
benchValidate forked one process per library and paired rounds inside those processes, so a process's own speed (where its code and heap landed, its JIT's choices) was read 30 times as its library's and the sign test counted the one offset as 30 independent wins. Rows with identical instruction counts came out significant: parse moltar arktype 1.198x at p < 1e-4, which measured 0.953x alone. A pass now forks fresh processes for every library, times each arm over BENCH_ROUNDS rounds (default 3, arm order rotating by pass and round) and keeps the median, so the samples are BENCH_PASSES (default 10) process draws and p is a sign test paired by pass. Each case is verified once before the first pass. Standings skip a case a filtered run leaves without another library to stand against, which threw. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…kes Standard Schema
From the headline run's failures, so written after that run and not part of
what it measured:
- filepizza: `bytes: z.unknown()` became a required key. Zod 3 and 4 accept
a missing key whenever its schema accepts undefined (checked on 4.3.6 and
zod/v3: `z.object({ a: z.unknown(), b: z.any() }).parse({})` is `{}`), so
the migrated schema rejected messages the original took (parity
0.995/0.986).
- shadcn-admin: four `z.boolean().default(false).optional()` keys lost their
default. Zod 4 still fills it in under a later `.optional()` (Zod 3 left
the key out), so the form's parsed output lost four keys (output 0.25 on
that schema).
- tweakcn: ai@5's `tool({ inputSchema })` takes Zod or `jsonSchema()` only;
Standard Schema arrives in ai 6 (provider-utils 4's FlexibleSchema). The
table row now says so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Objects built as output list their keys in declaration order, so migrated schemas under onUndeclaredKey: "delete" return keys in the order Zod did. Conflict in Structure.Inner: kept core-fixes' `undeclared?: UndeclaredKeyBehavior` (an explicit "ignore" survives a global "delete") and added decl-order's `order`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ep SKILL.md general
The first headline set (h1, 4 of 10 as frozen) is now a dev set, so these
come from its failures:
- repomix: `z.coerce.number()` became `type('number | string').pipe(Number)`,
rejecting the `null` Zod coerces to 0 (parity 0.988/0.972). The guide now
says the input stays "unknown", in a tool's input schema too; its Standard
JSON Schema is then `{ default: 0 }`, which loads.
- repomix also upgraded the MCP SDK to v2 on its own so it would take ArkType,
then loosened a test the new API broke. The guide now says to keep other
dependencies' major versions and keep Zod for an API that takes only Zod.
- lossless-cut: `JSON5.parse(str) as unknown`, a widening cast tsc didn't
need, failed the no-new-casts criterion. The guide now names `as unknown`.
- shadcn-admin: `out as t` in a helper over `type.Any<t>`, where
`<T extends type.Any>` is cast-free, and object refinements moved into a
second schema, so the form's own schema accepted what Zod rejected (accept
0.949). Both are named now.
- An agent's `yarn add arktype` replaced the build under test: the guide
says to keep an installed arktype's version (the harness also pins it).
SKILL.md's migration section is now a general summary that sends agents to
references/migrating.md, which every h1 run read; the Zod specifics it
repeated (undeclared keys, unions, optional keys, coercion, snapshot order)
were already there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A unit whose node holds a constant the compiled code shouldn't recreate,
e.g. string.email's RegExp, is a template call: `const [allows, apply] =
template(/^...$/)`, where the template is a function of its constants
returning the unit's functions. In the flat function a template called
once is now written as its call's function, `((R0) => { ...; return
[allows, apply] })(/^...$/)`, which flattens as any factory: its constant
is a const of the function, evaluated once where the call was, and its
functions are the unit's members. A template called more than once keeps
its declaration and calls. An inlined template's name is no longer the
function's to return, so a module importing it reads its own module.
The functions' parameters then minify alike with the rest of the
function's (`(r,i)=>` throughout rather than the template's own `(o,d)=>`),
which gzip finds: 12 B less of the login form's bundle is 34 gz.
gzip (claim-all-work/sizes.ts on built out/), previous commit -> this:
- valibot.dev's login form: 1297 -> 1263
- homepage login 1584 -> 1558, saas 12088 -> 12061
- Sury, sb (esbuild and Rolldown), moltar, shop: unchanged (no template
called once)
The login form's and Sury's bundles behave the same (behave.ts).
…t's passed A factory's parameter bound to a runtime export, e.g. a unit's requiredSite passed to each key's failsIn, became an alias in the flat function, `const xR1 = runtime.requiredSite`, that the minifier keeps (`a=k`). Where the factory only passes the parameter as a call's argument, its references now read the runtime export itself. A parameter that's called or whose members are read (isParsableUrl, a RegExp) keeps its alias, so V8 still calls a constant of the function rather than a module variable, as flat-units chose; an argument is only read when a key fails. gzip (claim-all-work/sizes.ts on built out/; sbDownload.ts), previous commit -> this: - valibot.dev's login form 1263 -> 1259, Sury 1248 -> 1241 - sb download (Rolldown) 2239 -> 2235, sb (esbuild) 2282 -> 2274 - login 1558 -> 1553, moltar 1611 -> 1612, shop 6896 -> 6888, saas same
A flat function read for one value, as a single-validator program's is,
returned it in an array its declaration destructured:
`const [x] = (() => { ...; return [x] })()`. It now returns the value:
`const x = (() => { ...; return x })()`.
gzip (claim-all-work/sizes.ts on built out/; sbDownload.ts), previous
commit -> this:
- valibot.dev's login form 1259 -> 1255, Sury 1241 -> 1237
- sb download (Rolldown) 2235 -> 2226, sb (esbuild) 2274 -> 2271
- login 1553 -> 1549, moltar 1612 -> 1608, shop and saas unchanged
Without the brackets, minifiedLengthOf no longer finds a one-unit
function larger than the unit's module. For `type("string.uuid")` alone
it estimates 201 against 202, as it reads `const r = runtime.isUuid` and
the declaration after it as one name, where esbuild's minified output is
16 B (3 gz) larger flattened. That fixture was the last the test of a
unit kept a module of its own where the function is larger had, so the
test is removed; each testPruneParity row still saves. esbuild annotates
the function's call `/* @__PURE__ */` in unminified output, as it does any
IIFE, so the test that the bundle keeps no annotation reads the
function's body.
A unit whose function only calls another with its own parameters, as a string.url predicate's Allows does (`(data, ctx) => R1(data, ctx)`, R1 holding isParsableUrl), stayed a function of its own in the flat function, called through. Where every reference to such a forwarder calls it, and what it calls is a constant of the flat function, the calls now name that constant, and the forwarder, unread, is left out. The call V8 reaches is still a constant of the function, as flat-units keeps it. gzip (claim-all-work/sizes.ts, SRC=1 AOT_SRC=1), previous commit -> this: - sb (esbuild) 2182 -> 2175, shop 6849 -> 6841, saas 11923 -> 11911 - the login form, Sury, login, moltar: unchanged
failsIn reports a required key it finds missing through failsAt, passing `true` so failsAt took the key as absent without checking. failsAt checks a key site's missing case anyway (`site.missing && !(key in data)`), and the site failsIn passes is the key's, whose missing context is set, so the flag only skipped a second `in` on a path that runs once a key is missing. It's gone from ArkErrors, its lean copy, InternalTraversal, ArkTraversal and the unified failsAt size builds call, and FailureContext no longer declares it. Gzipped bytes, each suite's bundle built from the packages' source as the suite builds it, before and after: - valibot.dev's login form: 1,206 -> 1,196 - Sury's schema row: 1,168 -> 1,157 - the homepage's apps: login 1,513 -> 1,507, moltar 1,550 -> 1,545, shop 6,841 -> 6,837, saas 11,911 -> 11,906 - schemabenchmarks.dev's download entry: 2,175 -> 2,169
Compiled code reports each failure by calling its ctx's failsAt or failsIn, and a minifier renames variables but not properties, so every bundle reading errors wrote both names in full at every failure site: sb's download entry 44 and 19 times, the homepage's saas app 195 and 181. They're now _at and _in, in ArkErrors, its lean copy, InternalTraversal and ArkTraversal; FailureContext's comment says what each was called. Compilers still ask for a failure by its long name, so the unified size builds' runtime.failsAt and runtime.failsIn helpers keep theirs. They were kept by 7daf33b as a narrow's ctx can reach them, but both are @internal and new since 2.2.9, so no released code calls them. failFast, which 2.2.9's Traversal exposes, keeps its name. Gzipped bytes, each suite's bundle built from the packages' source as the suite builds it, before and after: - valibot.dev's login form: 1,196 -> 1,191 - Sury's schema row: 1,157 -> 1,150 - the homepage's apps: login 1,507 -> 1,502, moltar 1,545 -> 1,538, shop 6,837 -> 6,815, saas 11,906 -> 11,822 - schemabenchmarks.dev's download entry: 2,169 -> 2,155
… function writeFlat declares a unit written as an array of its functions, e.g. `const x = [allows, apply]`, as one declarator per member, `const xAllows = allows, xApply = apply`, by rewriting each comma between elements and removing the closing bracket. A trailing comma survived that rewrite. errors "none" writes a unit without its Apply as `[allows, ]`, so the flat module read `const xAllows = allows, ` followed by the next unit's `const`, a SyntaxError on import. The differential job caught it only under errors "none" (and the configs whose units end the same way: mutatesInput with errors "first"/"none", optimize size with errors "first", optimizeErrors size with errors "none"). Every artifact the flat layout wrote with such a unit threw "Unexpected token 'const'". Running testDifferential locally with the default options never writes one, which is why it passed locally. The Node version made no difference: the failure reproduces on Node 24 and 25. The trailing comma is now found the way the commas between elements are, past parentheses and comments, and removed along with the bracket. A test flattens units whose arrays end with a trailing comma, a comma in a comment, a line comment, and spaced commas with two members, then checks that the module parses and pins what it writes.
…af33b Every ArkType row in the board's 13 suites is now one commit's, lane/mangle 7daf33b (claim-all's final run): moltar's counts, the homepage's counts and app sizes as production builds, the login form, schemabenchmarks.dev's counts and download, Sury's parse, build and schema size, and ata's suite. results/arktype-7daf33b7e.json holds them in each suite's own format, and leaderboard.ts lays them over each suite's rivals, none of which depends on ArkType's build. startup.ts was run whole at that commit, rivals included, and replaces the 4fd8806 run. The homepage's speed suite is counted in instructions, as "Which suites" counts wall-clock rows measured under load. Its rivals are perf-lead's counts at 54777c69a: ajv, Valibot and z.compile. Zod, Zod Mini, Typia and TypeBox aren't in them. None of those is fastest on any homepage row by wall clock, so ArkType's score doesn't move without them. With the suite in benchData.json's ns and every library in, ArkType still leads speed, 1.116 to ata's 1.253. Standings (geometric mean of ratios to each row's best): speed ArkType 1.092, ata 1.253 (3 of 5 suites) startup ArkType 1.014, Typia 2.706 (3 of 4) size Valibot 1.360, ArkType 1.462, Superstruct 1.464 (2 of 4) The board follows that. Each card states ArkType's standing, which is a superlative only where it leads ("Fastest on average across 5 public benchmarks", "Quickest to start ..."). Those carry the check mark and an asterisk to a one-line note under the cards, with the method link. claims.ts throws if either stops leading. Size has no superlative. Its card draws the best averages through ArkType's ("2nd of 14") and says Valibot's is best. Its build is now the default build, as on the other cards, so the board stamps "one build". The cards run speed, startup, size, and the title drops "Ships less": "Runs faster. Starts lighter." The startup card's login numbers come from the new run: 1.13M instructions against Valibot's 2.35M and Zod's 43.75M, fewer than each in 20 of 20 on Node 24 (p 1.9e-6). The headline stays pending.
…e the average is ArkType's The board's title, its note and the size card now follow the leaderboard for every category alike. Where ArkType has the best average bundle size, the card reads "Smallest on average across N public benchmarks" with the check mark and asterisk, its sentence names the runner-up, the title adds "Ships less." and the note counts the size rows. Otherwise it gives ArkType's place and says which library leads, as now (2nd of 14: Valibot 1.360, ArkType 1.462). The board's own throw is gone: every board superlative is computed from the leaderboard, so it can't disagree with it. The throw still guards the hand-written headline once it isn't pending.
startupChart's bars were copied by hand from startup.ts's results, so a rerun had to be copied again. They now come from the login row of the startup suite in leaderboard.json, net of the baseline and in millions to two places, the same numbers (1.13M ArkType, 2.35M Valibot, 43.75M Zod at 7daf33b). resultsOf in lib/bench/leaderboard.ts reads any suite's row.
…lled A function the flat function declares whose only read is a call passing its own parameters, as a union's Apply calls each branch's Apply (`case "number": return intersectionApply(data, ctx)`) and a string predicate's Apply calls the predicate's, is now its body at the call: its expression in parentheses, or its statements in place of the call's `return` where they end in a return. The function's declaration goes. Each name the body reads other than its parameters and its own declarations must be declared once in the function, so it reads the same binding where it's called. Units' traversals read neither `this` nor `arguments`, so an arrow's body means the same at the call. A parameter the body writes, a `var` it declares and a call inside the function itself keep the call. The forwarder pass shares the check that a call passes a function's own parameters. These calls are on the error path, which Apply is, so valid data runs the same code. The passes parse the function's body with what it returns after a `;`, which the span of the body's last statement takes in. Removing that statement, as this pass does where the function it writes in place is declared last and as withoutUnread does where the last one is unread, removed the `;` too, and the suffix sliced off by its length then took the character before it: a record's structure Apply declared last lost the `)` closing the Allows before it, a SyntaxError on import. Both passes now remove a statement only up to the end of the body. gzip (claim-all-work/sizes.ts, source mode; sbDownload.ts), previous commit -> this: - sb (esbuild) 2175 -> 2156, sb download (Rolldown) 2154 -> 2141 - shop 6841 -> 6785, saas 11911 -> 11846 - valibot.dev's login form, Sury, login, moltar: unchanged (no such call) errors "none", mutatesInput + "none", optimize "size" + errors "first" and mutatesInput + errors "first" build.
A validator nothing calls is written as an object literal of the members
read off it, so a program reading only `X.assert` creates
`{ assert: data => ... }` and reads it back with `X.assert(data)`. Where
the flat function creates such a validator in a site's place and the
site's declaration is the binding's only one, the site module's reads are
all `.assert` member reads and the binding isn't exported, the function
now creates the assert function itself, `const createXValidator =
data => ...`, and each `X.assert` in the site reads `X`. The assert is an
arrow, so calling it as `X(data)` rather than `X.assert(data)` gives it
the same `this` it ignores; a validator read any other way, or whose
object holds any other member, keeps both.
The usage analysis already guarantees the reads: an object form is only
written where every read of every validator is a known member, and a
destructured or passed validator escapes it. The site check is local to
the module, so it holds for whatever the analysis saw.
gzip (claim-all-work/sizes.ts, source mode), previous commit -> this:
- valibot.dev's login form 1206 -> 1198, Sury 1168 -> 1160
- login, moltar, shop, saas, sb (esbuild and Rolldown): unchanged (their
sites read more than assert, or call the validator)
The form's and Sury's bundles behave the same (behave.ts).
messageOf wrote each index below 256 from a cache, `[0]` once per index a message had used, and the rest as a template. Every bundle reading errors shipped the cache and the branch choosing it; each index segment is now its template. This trades error-path speed for size. A message is written when errors are read: once per issue an index into an array reaches. The homepage's errors product row, whose issues sit in arrays, runs 27,570 -> 30,324 instructions (+10.0%), first product 1,645 -> 1,706, first items 2,956 -> 2,999. ata's errors-invalid rows run user 3,729 -> 3,752 and order 11,517 -> 11,607. Rows whose paths hold no index, and every valid row, don't move; schemabenchmarks.dev's standard invalid row measured 20,101 -> 20,101. On the 11-suite board, these rows' changes applied over its other rows as last measured, speed stays ArkType's: 14.6% -> 14.4% ahead of ata, and 4.8% -> 4.6% with schemabenchmarks.dev, its widest lead, left out. Size goes from a 0.5% lead over Valibot to 1.8%. Gzipped bytes, each suite's bundle built from the packages' source as the suite builds it, before and after: - valibot.dev's login form: 1,183 -> 1,165 - Sury's schema row: 1,142 -> 1,120 - the homepage's apps: login 1,502 -> 1,486, moltar 1,538 -> 1,519, shop 6,754 -> 6,735, saas 11,797 -> 11,779 - schemabenchmarks.dev's download entry: 2,138 -> 2,121
The private traversal names and Standard Schema changes on this tip alter @ark/schema's emitted declarations; buildDocs rewrote them.
…ories claim-all's final run measured every ArkType row again on fee0e86db, whose tree 90d5104 has: results/arktype-90d510484.json and a whole startup.ts run, results/startup-90d510484-node24.json, both written from its finalrun directory. The 7daf33b files stay, as the README's record of the board before it. speed ArkType 1.096, ata 1.251 (3 of 5 suites) startup ArkType 1.013, Typia 2.791 (3 of 4) size ArkType 1.372, Valibot 1.400, Superstruct 1.464 (2 of 4) Size moves on every ArkType row: the login form 1315 to 1172 B gzipped, the homepage's apps 1583/1610/6893/12241 to 1489/1517/6718/11794, schemabenchmarks.dev's download 2298 to 2095 and Sury's schema 1264 to 1124. The lead is 2.0%. ArkType is still behind ajv on the form and the apps, and behind @railway-ts/pipelines, Typia, Superstruct and Valibot on the download. startup.ts now has ArkType fewest on all four apps, moltar's included (1.05M to Typia's 1.16M, 19 of 20). The board computes each card from the leaderboard, so it now reads "Smallest on average across 4 public benchmarks", titles itself "Runs faster. Starts lighter. Ships less." and counts the size rows in its note. The headline stays pending for David.
integrate's eslint now asks for braces around multi-line loop and if
bodies (curly), which the best-average and bench-survey files, merged from
lanes cut before it, didn't have: claims.ts, leaderboard.ts, standings.ts,
ata/arms.ts, sury/arms.ts and sb/allChild.ts get braces, nothing else.
sb/precompiled/ is copied into a schema-benchmarks clone and written in
its style (two spaces, semicolons), and its benchmarks.ts imports a Bun
macro (with { type: "macro" }) that crashes eslint's import plugin. Both
prettier and eslint now skip it, so it stays byte for byte what was
measured.
With size led too, the title reads "Runs faster. Starts lighter. Ships less." and fills the row on desktop, where the stamp, pinned to the title's baseline, sat on "less". It now sits at the top right, level with the section label. Mobile already sets it below the title.
…ing its Allows A standalone Apply walked an object's required keys as `leafAllows(data.k) || !ctx._in(leafApply, data.k, "k", site, data)`: a leaf whose Allows passed skipped the path push and pop of applying it. Apply runs only once a root's Allows has rejected data, so the gate is error-path code, and every key holding a leaf repeated a call to its Allows next to the call of its Apply. Each key now calls `_in` alone; the leaf's Apply reports nothing for a value it allows and returns true. Only the error path changes. Allows, Transform and the root validator are byte-for-byte as before, so valid data runs the same instructions. The gate's hook (NodeCompiler.gate) and its leaf test go with it, along with the copy of the hook the runtime-call compiler overrode to do nothing. gz, source mode (claim-all-work/sizes.ts SRC=1 AOT_SRC=1), parent -> this: - form 1165 -> 1161, login 1486 -> 1480, moltar 1519 -> 1519 - shop 6735 -> 6705, saas 11779 -> 11636 - sb (esbuild proxy) 2121 -> 2084, Sury 1120 -> 1120 optimize "size", errors "first" and errors "none" still build. Built and measured as the board measures each row (finalrun), parent -> this: form 1172 -> 1168, login 1489 -> 1484, moltar 1517 -> 1517, shop 6718 -> 6692, saas 11794 -> 11668, sb download (Rolldown) 2095 -> 2074, Sury 1124 -> 1124. Size category lead over Valibot 2.0% -> 2.5%. The cost is on rows that fail objects with several valid leaf keys, which now pay the path bookkeeping of each. Homepage instructions per call, both commits counted side by side (5 repeats each), parent -> this: issues product 15898 -> 16614 (+4.5%), errors product 30324 -> 31023 (+2.3%), first product 1742 -> 1837 (+5.5%, a row whose runs spread +-7%), every other row within 0.5%, every valid row 1.000 (strip product 0.990, noise: its validator never calls Apply). ata errors-invalid order +4.4%, user +1.4%, valid rows 1.000. Speed category lead over ata 14.2% -> 13.9%. testDifferential 0 disagreeing (484 flattened, 3492 passing), pnpm test 3498 passing (prune's webpack/Rspack cases need out/: 71/71 after a build), typecheckRepo and lint clean.
A required key's site, `{ value, missing }`, was a const its unit
declared, `runtime.keySiteOf("domain", "a string")`, read at each key
failing it. A site of a code and description alone was already written
where it fails; a key's site of one is now too, as the same keySiteOf
call, so a unit declares none and a failing key builds its site as a
failing leaf does. Keyed sites holding configured text, written
messages or holes stay declared.
Only failures change: a failing key allocates its site (two objects),
where it read a shared one. 7ade496 measured this form against the
shared const: moltar startup -0.12M instructions, moltar invalid +1.8%
per call.
gz, source mode (claim-all-work/sizes.ts SRC=1 AOT_SRC=1), parent -> this:
- form 1161 -> 1161, login 1480 -> 1480, moltar 1519 -> 1499
- shop 6705 -> 6688, saas 11636 -> 11611
- sb (esbuild proxy) 2084 -> 2070, Sury 1120 -> 1104
optimize "size", errors "first" and errors "none" still build.
Built (finalrun), parent -> this: moltar 1517 -> 1501, shop 6692 ->
6665, saas 11668 -> 11633, sb download (Rolldown) 2074 -> 2062, Sury
1124 -> 1112, form and login equal. Size lead over Valibot 2.5% ->
3.0%. Counted (3 repeats, against the parent's 5 in another run):
issues moltar strict 1641 -> 1761 (+7.3%), errors moltar strict 2448 ->
2574 (+5.1%), issues product +3.8%, errors product +1.9%, other rows
within noise, every valid homepage row 1.000; ata errors-invalid order
+0.2%, verdict-valid order 1.002. Startup (startup.ts, instructions):
moltar 1.050M -> 0.998M, shop 10.47M -> 10.37M, form 1.038M -> 1.025M
(against final-D's run, not side by side). Speed lead 13.9% -> 13.8%.
testDifferential 0 disagreeing (432 flattened: fewer units' estimates
favor one function once their sites stop being declarations), 3490
passing (the 2 failing are prune's webpack/Rspack builds, which need
out/: 71/71 after a build), typecheckRepo and lint clean.
The required-key site changes alter @ark/schema's emitted declarations; buildDocs rewrote them.
The registered rule table names the homepage's benchmark by its median ns per call from benchData.json. 2b07438 switched the suite to instruction counts (results/homepage-counts-*.json) because the machine was loaded, and edited the table's row to match. The rule was fixed before any average was computed, so the row goes back to its registered wording and leaderboard.ts reads benchData.json's scenarios again, every library from the one run, with no ArkType overlay. The counts files stay as the record of that run. With benchData.json as it stands (8951070's run) speed is ArkType 1.117, ata 1.251; the wall-clock rows are rerun next on a quiet machine.
benchData.json's scenarios are a fresh run at fea8e68 by its own procedure (BENCH_PASSES=20 BENCH_ROUNDS=3 BENCH_CORE=13,15 benchValidate, then a 10-pass replica), 11:41-12:21 UTC on a quiet machine, 1-minute load 2.1-3.4 at the runs' starts and ends. Every library is in it: the default and first-error builds, dynamic ArkType, Zod, Zod Mini, z.compile, Valibot, ajv, Typia and TypeBox. The old wall-clock rows predated e7fae8a, which made the transforming rows return the same output in every library. z.compile no longer beats the default build on items morph or moltar morph in both runs; ajv now does on first product as well. Sizes and the cold start in benchData.json are unchanged. Every build-dependent ArkType row was measured again at the same commit (claim-all's finalrun steps: sizes, schemabenchmarks.dev's download, Sury, moltar, ata, schemabenchmarks.dev's counts, startup.ts on Node 24) into results/arktype-fea8e68e0.json and startup-fea8e68e0-node24.json. The two size trades show: login form 1172 -> 1168 B, homepage apps' login 1489 -> 1484, saas 11794 -> 11633, schemabenchmarks.dev's download 2095 -> 2062; Sury's schema 1124 -> 1112. Standings (geometric mean of ratios to each row's best): speed ArkType 1.105, ata 1.249 (13.0%); homepage suite 1.298, ajv 1.972 startup ArkType 1.013, Typia 2.815 (178%) size ArkType 1.363, Valibot 1.404 (3.0%) The headline "TypeScript's smallest, fastest validator." is live: size and speed both lead, and claims.ts throws if either stops leading.
107bbfa held a failing call's first issue in an array of its own size, replacing the empty one the errors object starts with (`if (this._.length) this._.push(issue) else this._ = [issue]`), as pushing to an empty array allocates 17 slots. That saved instructions on the error path, single-issue rows most (moltar issues -10.9%, errors -6.8%, first -6.9% then), at +9 gz a bundle. Every precompiled bundle carries ArkIssues, so the branch costs every size row, the single-schema suites (form, Sury, schemabenchmarks' download) most. failsAt now pushes every issue, as the lean issues and the JIT's errors already do. Only failures change: valid data never reaches failsAt. Taken as a trade, size over invalid-path instructions. gz, source mode (claim-all-work/sizes.ts SRC=1 AOT_SRC=1), parent -> this: - form 1161 -> 1152, login 1480 -> 1472, moltar 1499 -> 1492 - shop 6688 -> 6678, saas 11611 -> 11603 - sb (esbuild proxy) 2070 -> 2061, Sury 1104 -> 1096 settingsValidators.ts regenerated (its copy of ArkIssues). Built and measured as the board measures each row (finalrun), parent -> this: form 1168 -> 1158, login 1484 -> 1478, moltar 1501 -> 1494, shop 6665 -> 6660, saas 11633 -> 11626, sb download (Rolldown) 2062 -> 2054, Sury 1112 -> 1104. Size lead over Valibot 3.0% -> 3.6%. Homepage instructions per call, both commits counted side by side (5 repeats each), parent -> this: issues moltar 722 -> 833 (+15.4%), errors moltar +9.4%, first moltar +9.2%, first moltar strict +5.9%, issues items +10.9%, errors items +8.1%, first items +8.9%, first product +4.4%; rows with several issues gain (issues moltar strict 1761 -> 1619, -8.0%; errors moltar strict -5.8%; issues/errors product -1%); every valid row within 0.4%. ata errors-invalid +1.0..1.7%, verdict-invalid +0.4..1.1%, valid rows 0.998-1.000. Speed lead over ata 13.8% -> 13.5%. Full gate (fullgate.ts: lint through testPruneParity, both differential configs) green.
An object's keys are now checked in the order its definition declares
them, so `type({ name: "string", age: "number" })` given `{}` reports
name before age, where it reported required keys first, then optional
ones, each sorted. That's the issue a reader looking down the definition
expects first, and the one validators that walk declaration order, like
ajv, stop at: in the benchmarks' first-error rows ArkType found its first
issue late because it walked keys sorted.
Declaration order was already part of a structure's identity (its inner
`order`, in the node cache key but in no json), so types differing only
in key order are distinct nodes and each traverses its own order,
whichever was created first; nothing keyed by hash shares their compiled
code. This change only reads it where traversal read the sorted props:
- Apply, dynamic and compiled (JIT and every precompiled compiler, which
compile structures through the same node)
- transforms, so morphs on an object's values run, and report, in that
order too
- Allows, which still checks cheap keys first but now breaks ties, and
stops at a callback, in declared order, so it never runs a callback
before a key Apply would check first
- the size build's `props` checker, now `props(declared, optional?)`,
whose first argument lists every key's checker as declared and whose
second lists the optional keys, since required and optional keys can
interleave; `transformingProps` takes the same arguments before its
defaults
An encoder made each defaulted key required without keeping its place,
so a type declared in sorted order but for its defaults lost its order
in the encoder; it now passes the order explicitly.
Composition follows the order an output's keys already had: an
intersection or merge checks the left operand's keys, then the right's
new ones, and pick, omit, partial and required keep their source's
order. Declared keys are still checked before a sequence, an index
signature's keys and undeclared keys, which follow the data's order.
`expression`, json, hashes and JSON Schema stay sorted.
…08927f 808927f pushes the first issue directly (d326f7f, a size trade) and reports object issues in declaration order (lane/issue-order), so every build-dependent ArkType row was measured again with claim-all's finalrun steps (sizes, schemabenchmarks.dev's download, Sury, moltar, ata, schemabenchmarks.dev's counts, startup.ts on Node 24): results/arktype-808927feb.json and startup-808927feb-node24.json. Sizes, gzipped: login form 1168 -> 1158 B, homepage apps' login 1484 -> 1478, saas 11633 -> 11603, schemabenchmarks.dev's download 2062 -> 2037, Sury's schema 1112 -> 1107. moltar's counts are unchanged. benchData.json's scenarios are a fresh run by their registered procedure (BENCH_PASSES=20 BENCH_ROUNDS=3 BENCH_CORE=13,15 benchValidate, then a 10-pass replica), every library in, 14:07-14:49 UTC. Each run started under a 1-minute load of 4; the replica ended at 4.9, when unrelated processes started. In the lost-in-every-run caveat, ajv no longer beats the default build on issues moltar strict. Standings (geometric mean of ratios to each row's best): speed ArkType 1.103, ata 1.255 (13.8%); homepage suite 1.282, ajv 1.963 startup ArkType 1.013, Typia 2.815 (178%) size ArkType 1.355, Valibot 1.405 (3.7%) The headline "TypeScript's smallest, fastest validator." stays: size and speed both lead, and claims.ts throws if either stops.

Based on 2.2.7 (1ee4923): 565 commits, 189 files. A draft for review by area; the history gets rewritten into reviewable ranges after review.
Suggested reading order (each a
git diff 1ee4923b..featherduster -- <paths>):ark/util,ark/regexarksetspackage:ark/sets,ark/schema/shared/{intersections,sets}.ts(git diff 3166b30ab featherduster -- ark/setsshows what changed after the verbatim move)@ark/schemacore and perf, excluding cycles and Transformclone:ark/schema/shared/transform.tsark/type(public API, lazy keywords,ArkErrorsno longer extends Array, CHANGELOG)ark/repo/{bundle,build,testBuild,publish}.ts, per-packageinternal.tsark/schema/roots/alias.ts,ark/schema/generic.ts,ark/schema/shared/utils.ts,ark/type/__tests__/cyclic*(a rewrite follows as its own PR)ark/docsKnown before review:
allowsthrows a RangeError on valid rings from 38 members (12 jitless).Issues this closes
Each was verified against this branch by a test or probe.
Closes #1121
Closes #1473
Closes #1306
Closes #1470
Closes #1529
Closes #1555
Closes #1548
Closes #1530
Closes #1577
Closes #1430
Closes #1600
Closes #1676
Closes #1445
Closes #1160
Closes #1023
Closes #579
Closes #928
Closes #930
Closes #944
Closes #1026
Closes #1630
Closes #1237
Closes #1082
Closes #1476
Closes #1486
Closes #1587
Closes #1437
Closes #1457
Closes #961
Closes #1380
Closes #1543
Closes #1568
Closes #1598
Closes #1370
Closes #1403
Closes #909
Closes #1485
Closes #810
Closes #1498
Closes #1243
Closes #1273
Closes #1481
Closes #1412
Closes #1532
Closes #1490
Closes #1390
Closes #1575
Closes #1492
Closes #1479
Closes #1337
Closes #1310
Closes #1663
Closes #1525
Closes #1448
Closes #1435
Closes #1491
Closes #1287
Closes #963
Closes #1524
Closes #1571
Closes #1475
Closes #1554
Closes #925
Closes #751
Closes #1707