TypeScript ↔ Go ↔ Rust parity#
Shape has three implementations. TypeScript is canonical—it defines the behaviour. The Go and Rust ports match it exactly for validation outcomes, produced values, and error message text. The page keeps its Go-centred shape, since the Go port came first and its divergences are the longer list; the Rust port's own section is at the end.
How parity is enforced#
Two gates, and they do different jobs.
The shared corpus#
A language-neutral conformance corpus lives in test/
as a set of .tsv files. Each row is a (spec, input) → output | error case
whose expected column is computed from the canonical TypeScript build. All
three implementations run every row:
- TypeScript—
ts/test/compat.test.ts - Go—
go/compat_tsv_test.go - Rust—
rs/tests/compat_tsv.rs
A row's output is compared JSON-normalized (so numeric widths and absent
properties don't cause spurious mismatches). Its error column holds the
complete expected message and is compared exactly—a substring check
cannot see a wrong separator, a wrong error order or an extra error, and those
are precisely the ways two implementations drift apart.
The differential harness#
The corpus only covers rows someone thought to write, and for a long time it
had no negative case for Type() or Rest()—so a Type() that asserted
nothing and a Rest() that validated nothing passed it.
test/differential/ is the wider net. It
generates thousands of (spec, input) pairs, runs every one through all
three implementations, and diffs the JSON Schema export, the declarative JSON
export, the export of the shape that JSON reads back as, the verdict, the
produced value and the exact error text, each port against the canonical
build:
make diff # sampled report, both ports
make diff-full # every mismatch
make diff-rs # the Rust port alone
Run it after any behaviour change, and promote anything it finds into a corpus row so the committed gate keeps it closed.
What is guaranteed to match#
- Validation outcomes: pass/fail, injected defaults, produced values.
- Error message text, including the aggregation separator (a newline), the
order errors are reported in, the
undefined-vs-nullrendering, theindex-vs-propertywording, and how a node renders inside a compositeOne/Some/Allmessage. - The builder set, the chainable-method set, and the string DSL
grammar—including the coercions, the string formats, the isolation builders
(
Catch,Transform,Ignore), discriminated unions and the object algebra (Pick,Omit,Partial,Extend). - The JSON Schema export: the same shape renders the same document.
- The declarative JSON export: the same shape writes the same JSON, that JSON reads back as the same shape in every language, and each language reads what the others wrote. See Serialize a shape; the differential harness compares both the export and the export of what it reads back, on every case.
Intentional divergences#
Some differences are inherent to Go and are unlikely ever to close.
Object key ordering. Go maps are unordered, so object specs and argument specs are processed in alphabetical key order; TypeScript preserves insertion order. This affects three things: the order of multiple errors, the meta-key adjacency rule, and how an object value is rendered inside a message—validating a closed
{a: 1}against{b: 1, a: 2}names the value{b:1,a:2}in TypeScript and{a:2,b:1}in Go. Name argument keysa,b,c, … to fix positions. The produced value itself is unaffected: it is the same object either way, and the differential harness compares it canonically.Sharing with the input. TypeScript produces in place:
valid(),error()and the producing call write defaults into the input object itself (the documented behaviour of the canonical implementation). Go never changes its input;Validatecopies an object or array on the first write that changes it and otherwise returns it as it is, so its result may share structure with the input where nothing changed. The produced value is the same in both; what differs is which objects are the input's own.Regular expressions. Go uses the RE2 engine (
regexp), Rust theregexcrate and TypeScript the JavaScript engine, which agree only on a subset. Every pattern is held to that subset at build and rewritten for the engine, so the same pattern matches the same strings in all three; what is outside it (lookaround, backreferences, flags,\pclasses) is refused with the same text everywhere. The regexp subset page states it, andtest/regexp.tsvpins it.An explicit null at the root. Go cannot tell a missing argument from a nil one, so
Validate(nil)means "no value supplied" (JSundefined) and defaults fill, mirroring TSShape(x)(). To mean a value that is present and null—a type error against a typed shape—pass the exportedNullsentinel. Inside a map or slice a plainnilalready reads as present-null, because the key or index exists.No Symbol. TypeScript has a
Symboltype token and a.Symbol()chain shortcut. Go has no equivalent concept.No
.String()chain shortcut. Go has.Number(),.Boolean(),.Object(),.Array()and.Function(), but a method namedStringon an exported type reads asfmt.Stringerandgo vetrejects the signature. Use.Type(String), which is what the shortcuts call anyway.Any,IntegerandDateare tokens, not builders. In TypeScriptAnyandIntegerare builder functions, usable bare ({ a: Any }) or called (Any()), andDateis the constructor. In Go they areTypeTokens; to narrow, useType(Any, spec)or the.Any(),.Integer()and.Date()chain methods.Construction faults surface at different times. TypeScript throws when a builder is called wrongly—
Discriminatedwithout a branch,Pickof an unknown property,Extendwith a non-object. A Go builder returns a*Nodeand cannot, so the fault surfaces at validation, as it does for any bad spec, with the same message on anevernode, without theShape:prefix the thrown message carries. In the string DSL both fail at build, a wrong builder argument included:exprthrows andExprreturns an error with the same text. The differential harness compares the string form's refusals, and otherwise valid constructions only.A validator that throws. TypeScript catches an exception thrown by a
Check,BeforeorAfterand fails the value with the exception's message appended (check "…" failed (threw: boom)). A Go validator that panics escapesValidateandStandard().Validatealike, and a Rust one unwinds: neither port recovers at the callback boundary, so a validator there must returnfalserather than panic.Composite messages render object and array branches differently. The
does not satisfy one oflist ofOne/Some/Allnames each branch. TypeScript renders a node branch in its spec form ({a:1,$$:Open},[String,Number],Exact(1,x), and[Number]for a rest tuple) and a plain object or array literal as JSON ({"b":"String","c":["Number"]}); Go and Rust render every branch from the compiled node, spaced ({a: 1}.Open(),[String, Number],Exact(1, x),[String, ...Number],{b: String, c: [Number]}). Scalar and chained branches (String.Min(2),/^a/,Number) agree, and those are what the corpus pins.Structs are a Go-only convenience. A struct is read into the same
map[string]anymodel that TypeScript's plain objects occupy, on the way in (as a value, by itsjsontags) and on the way out (ValidateInto), and a struct spec'sshapetags are ordinary key expressions. Nothing a shape accepts or produces changes; the differential harness never reaches a struct.
Error metadata#
All three languages produce the same message text, and the Go FieldError
and Rust FieldError carry the fields of TypeScript's ErrDesc (why,
check, mark, path, pathArr, …). The codes differ for the built-in
checks: TypeScript reports a bound, One/Some/All and Exact as
why: "check" with mark 4000 and the builder's name in check, while Go and
Rust report the builder's own code and mark (why: "Min", mark 4011;
why: "One", mark 4030). The corpus compares text only, so this is a
documented divergence rather than a gated one.
Contributing changes#
Because TypeScript is canonical, a behaviour change starts there:
- Change
ts/src/shape.tsand add/adjust a case intest/gen-compat.js. - Regenerate the corpus:
node test/gen-compat.js. - Run
make test—all three languages must pass the regenerated corpus. - Run
make diff—both ports must agree with TypeScript on every generated case. - If a port diverges, fix the port to match.
A divergence caused by a TypeScript bug is fixed in TypeScript first: never "fix" it by changing a port to match, and never change TypeScript to match a port without deciding that the TypeScript behaviour is wrong.
The Rust port#
The Rust port (rs/) is held to the same two gates:
every corpus row
(none skipped; make test-rs sets SHAPE_RS_STRICT=1, which turns a
skipped row into a failure) and every differential case. Two of the Go
divergences listed earlier do not apply to it:
- Key order. Its objects are insertion-ordered maps, so unknown keys and produced values keep the input's order, as TypeScript's do.
- The root value.
Value::Undefinedis a value of its own, sovalidateneeds no null sentinel to say "nothing was supplied".
Its own divergences, each deliberate:
- Construction faults. A builder called wrongly (
min("x", ..),define("", ..),pick("z", ..)) cannot throw, as in Go, and returns a node that accepts nothing and reports the message at validation. In the string form (Schema::parse, key expressions) the fault is an error at parse time, asexprthrows in TypeScript. Exactcompares by value. TypeScript compares object and array literals by identity, so anExactof an object matches nothing that was not that very object; Rust compares structurally. Scalars behave alike.- Error rendering of the value. A failing after check reports the value
as it stands at that point, an injected default included, as TypeScript
does (
with number "0"); Go renders the original absence. - No
Symboltoken, no functions as values. A function is an opaqueValue::Func(id)that only a spec written in Rust can carry, since JSON cannot. - String length is measured in UTF-16 units for the size bounds, as
JavaScript's
lengthis; Go measures bytes. shape!is the spec-by-example form, in thejson!style; the type tokens are bare words in it and Rust expressions stand for everything else.