shape

shape/go#

Go port of the shape schema-by-example validator. Your schema looks (almost) exactly like your data.

import "github.com/rjrodger/shape/go"

s := shape.MustShape(map[string]any{
	"port":  8080,          // optional, defaults to 8080, must be a number
	"host":  "localhost",   // optional, defaults to "localhost", must be a string
	"debug": shape.Boolean, // required, must be a boolean
})

out, err := s.Validate(map[string]any{"debug": true})
// out == map[string]any{"port": 8080, "host": "localhost", "debug": true}

The TypeScript implementation in ../ts is canonical: this port matches it for validation outcomes, produced values and exact error text, and a shared conformance corpus plus a differential harness keep it that way. The full documentation is in ../docs; this file is the Go surface in one place.

Install#

go get github.com/rjrodger/shape/go

Requires Go 1.22+. The module has no dependencies.

Concepts#

A schema is built from an example value. Literal values become optional with a default; sentinel tokens become required.

Sentinel tokens#

Go cannot use predeclared types as runtime values, so the package exports sentinels for each kind:

Token Matches
shape.Any any value (the one token that does not require a value)
shape.String strings (not the empty string, unless Empty)
shape.Number any numeric kind (int*, uint*, float*)
shape.Integer a number with no fractional part
shape.Boolean booleans
shape.Object map[string]any—open, as a token
shape.Array []any (typed slices are accepted and converted)
shape.Function reflect.Func values
shape.Date time.Time values

If you prefer a dot-import without colliding with stdlib names, G-prefixed aliases are provided for every token and builder: GString, GNumber, GRequired, GMin, GPick, etc.

Absent versus null#

Go has no undefined. A missing map key is absent—it may be defaulted or flagged required. An explicit nil value is a present null, a type error against a typed shape. At the top level Validate(nil) means "no value supplied" and fills defaults; pass shape.Null to mean a present null there.

Objects#

Objects are closed by default—extra keys cause a validation error. An empty map[string]any{} is open. Use shape.Open(...) to allow unknown properties, or shape.Child(...) to declare a shape for unknown values.

shape.MustShape(shape.Open(map[string]any{"a": 1}))          // extra keys allowed
shape.MustShape(shape.Child(shape.Number, map[string]any{})) // every value must be a number

Go maps are unordered, so an object's keys are processed in alphabetical order: that fixes the order of multiple errors and how an object value is rendered inside a message. The produced value is unaffected.

Structs#

A struct, or pointer to one, is accepted wherever an object is: it is read by its json tags (- hides, omitempty makes a zero value absent, embedded structs are promoted) into the map model, so it validates exactly as the map it encodes to. ValidateInto decodes the produced value back into a struct. A struct is also a spec by example, its fields the defaults and its shape tags the key expressions:

type Config struct {
	Host  string `shape:"Min(1)"`
	Port  int    `shape:"Min(1).Max(65535)"`
	Debug bool   `shape:"Boolean"` // required
}
s := shape.MustShape(Config{Host: "localhost", Port: 8080})

var c Config
err := s.ValidateInto(map[string]any{"Debug": true}, &c) // c.Port == 8080

Arrays#

A single-element array is treated as "every element matches this shape":

shape.MustShape([]any{shape.Number}) // []number

Multiple elements form a tuple of fixed length. Use shape.Rest(...) to allow a tail beyond the tuple positions.

API#

Compilation#

shape.Shape(spec)                      // compile, returns (*Schema, error)
shape.ShapeWith(spec, shape.ShapeOptions{...})
shape.MustShape(spec)                  // panics on compile error
shape.MustShapeWith(spec, opts)
shape.Build(spec)                      // like Shape, but recursively expands string DSL
shape.Expr("String.Min(2).Max(10)")    // parse the string DSL into a *Node
shape.MustExpr(...)
shape.IsShape(v)                       // is v a *Schema?

Validation#

out, err := s.Validate(input)         // returns the (defaults-injected) value plus *ValidationError
out, err := s.ValidateCtx(input, ctx) // pass a *shape.Context for custom validators
ok := s.Match(input)                  // bool, no errors collected
ok := s.Valid(input)                  // alias of Match
issues := s.Error(input)              // []FieldError, nil when valid
spec := s.Spec()                      // structural snapshot of the compiled schema
str := s.String()                     // debug rendering
j, _ := s.JSON()                      // the shape as declarative JSON
back, _ := shape.Build(j)             // and back: the same shape again
schema := s.JSONSchema()              // a JSON Schema (draft 2020-12), as map[string]any
spec, _ := shape.FromJSONSchema(doc)  // and back: a spec built from a JSON Schema
err := s.ValidateInto(input, &out)    // validate, then decode the result into a struct
std := s.Standard()                   // Standard Schema V1-style interface: Version, Vendor, Validate

Validate and Error never change their input. The value Validate returns is produced by copying on write: an object or array of the input that validates as it is comes back as itself, and one that changes (a default injected, a key renamed or dropped, a child produced as a different value) comes back as a copy, with the input left as it was. So the result may share structure with the input; take a copy before changing either if both are kept. (Before v0.4.0 every object and array was copied whether it changed or not.)

Injected defaults are deep-cloned, so two results never share a default's state. A validator attached to a node after Shape() has compiled it is not seen by the compile, so attach validators before compiling.

*ValidationError aggregates one or more FieldErrors, joined by newline in Error(); each carries Path, PathArr, Key, Type, Value, Why, Check, Mark, Args and Text. The message text is identical to the TypeScript implementation's.

Options#

shape.ShapeOptions mirrors the TS options. Defaults shown:

shape.ShapeOptions{
	KeyExpr: shape.KeyExprOptions{Disable: false}, // "x: Min(1)" key parsing — on
	Meta:    shape.MetaOptions{Active: false, Suffix: "$$"},
	ValExpr: shape.ValExprOptions{Active: false, KeyMark: "$$"},
}

With key-expression parsing on (the default), object keys may carry inline builders, and the value is the example the builder works on:

shape.MustShape(map[string]any{
	"name: Min(1)":           shape.String,
	"tags: Max(10)":          []any{shape.String},
	"port: Optional(Number)": 8080, // optional, defaults to 8080
	`user: Pick(["id"])`:     map[string]any{"id": shape.Number, "name": shape.String},
})

Builders#

All builders have a top-level form and, unless noted, a chainable method form on *Node. Most accept an optional spec argument that the builder narrows or wraps. The builder reference has the detail; the tables here list the Go signatures.

Required / optional / defaults#

Builder Effect
Required(spec?) mark required (no default injection)
Optional(spec?) mark optional
Default(value, spec?) optional with an explicit default
Skip(spec?) optional, no default injection
Ignore(spec?) like Skip, and drop the value if anything in its subtree fails
Empty(spec?) allow the empty string for a String shape
Nullable(spec?) accept an explicit nil as the value
Fault(msg, spec?) override the structural error message of this node

Type / equality / coercion#

Builder Effect
Type(kind, spec?) force a Kind, TypeToken, kind name or node's type on the node
Exact(values...) require equality with one of the listed literals (numbers by value, so Exact(1) matches 1.0; the rest by reflect.DeepEqual)
Never(spec?) always fails to match
Func(spec?) a function-typed value; optional of itself (the Function token is required)
Coerce(spec?) convert a string/number/bool to the node's kind first, where unambiguous
.Any(), .Integer(), .Date() chain shortcuts for the Any, Integer and Date tokens

String formats#

Email, Url, Uuid, DateTime, Ip, Ipv4, Ipv6—each (spec?), each requiring a string in that format; bare, a required string.

Bounds#

Builder Effect
Min(n, spec?) / Max(n, spec?) numeric value or collection length bounds (inclusive)
Above(n, spec?) / Below(n, spec?) strict bounds
Len(n, spec?) exact value or collection length

Custom checks and isolation#

Builder Effect
Check(fn or *regexp.Regexp, spec?) custom predicate
Before(fn, spec?) run before structural type checks
After(fn, spec?) run after structural type checks
Catch(fallback, spec?) replace whatever fails inside with fallback, raising nothing
Transform(fn, spec?) replace a valid value with fn(value, state)
Describe(text, spec?) attach a description, read back with n.Meta()["description"]

Custom-check signature:

func(val any, update *shape.Update, state *shape.State) bool

A *regexp.Regexp anywhere in a spec is a string that must match it.

Composition#

Builder Effect
One(shapes...) the first matching shape's output is used
Some(shapes...) at least one shape must match
All(shapes...) every shape must match
Discriminated(tag, branches) a tagged union: branches is a map[string]any keyed by tag value

All four are top-level only.

Objects / arrays#

Builder Effect
Open(spec?) / Closed(spec?) allow / forbid unknown object properties
Child(child, spec?) default child shape for an Open object or for an array
Rest(child, spec?) tail-shape for arrays past tuple positions
Rename(name, spec?), RenameWith(name, opts, spec?) rename an object property after validation

Object algebra#

Each returns a new node, leaving the source unchanged. names is a string, []string or []any.

Builder Effect
Pick(names, spec?) keep only the named properties (an unknown name is a fault)
Omit(names, spec?) drop the named properties
Partial(spec?) make every declared property optional (shallow)
Extend(extra, spec?) add the properties of extra; the base's openness and checks stay

References#

Builder Effect
Define(name, spec?) name a shape so it can be referenced later
Refer(name, spec?) substitute the named shape at validation time
ReferWith(name, opts, spec?) opts.Fill substitutes even when the input value is missing

Misc#

Builder Effect
Key(args...) replace the value with the validation key (or path slice)

Construction faults#

A builder called wrongly—Discriminated without a branch, Pick of an unknown property—returns a node that fails at validation with the message TypeScript would have thrown, since a *Node cannot carry an error. In the string DSL, Expr returns the error.

Example: composition and error handling#

s := shape.MustShape(map[string]any{
	"name":  shape.Min(1, shape.String),
	"age":   shape.Coerce(shape.Min(0, shape.Max(120, shape.Integer))),
	"email": shape.Email(),
	"role":  shape.Exact("admin", "user"),
	"tags":  shape.Optional([]any{shape.String}),
	"addr": shape.Open(map[string]any{
		"city": shape.String,
	}),
	"pet": shape.Discriminated("kind", map[string]any{
		"dog":  map[string]any{"bark": shape.Boolean},
		"fish": map[string]any{"fins": shape.Number},
	}),
})

out, err := s.Validate(input)
if verr, ok := err.(*shape.ValidationError); ok {
	for _, issue := range verr.Issues {
		fmt.Printf("%s [%s]: %s\n", issue.Path, issue.Why, issue.Text)
	}
}

Development#

go build ./... && go vet ./... && go test -cover -count=1 .

The package is held at 100% statement coverage, and Go has no coverage pragma: anything new is covered by a test or removed. go test also runs the shared corpus in ../test/*.tsv; make diff from the repository root runs the differential harness against the TypeScript build. expr.go and node.go carry original-port formatting that is not gofmt-clean—leave their unrelated regions as they are; every other file is gofmt-clean.

See ../AGENTS.md for the parity rules and the change checklist, and PLAN.md for the original porting plan.

Version#

const Version = "0.5.0"