Schema-driven validation & transformation engine
CUE constraints + Bloblang dynamic expressions, unified.
%%{init: {'flowchart': {'wrappingWidth': 340, 'curve': 'basis', 'nodeSpacing': 45, 'rankSpacing': 48}}}%%
graph TD
SRC["<b>CUE schema text</b><br/>constraints · @blob · @meta<br/>compiled once by New()"]:::schema
SRC --> FAST["<b>fastConstraint</b><br/>scalars only"]:::fast
SRC --> CV["<b>CUE schema value</b><br/>structs · arrays"]:::slow
FAST --> L1["<b>Layer 1</b> · constraint check"]:::layer
CV --> L1
L1 -->|all fields scalar| Z["<b>Go fast path</b><br/>no cue.Value · 0 alloc"]:::fast
L1 -->|struct · array · blob| E["<b>lazy Encode</b><br/>LookupPath · Unify"]:::slow
Z --> L2["<b>Layer 2</b> · @blob + @meta<br/>on the raw Go map"]:::layer
E --> L2
L2 --> R["<b>Result</b><br/>Valid · Output · Errors"]:::result
L1 -.-> OBS(["Metrics · OTel<br/>per layer"]):::obs
L2 -.-> OBS
classDef schema fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
classDef fast fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef slow fill:#ffedd5,stroke:#ea580c,color:#7c2d12
classDef layer fill:#f8fafc,stroke:#475569,color:#1e293b
classDef result fill:#e0e7ff,stroke:#4f46e5,color:#312e5f
classDef obs fill:#fafafa,stroke:#a1a1aa,stroke-dasharray:3 3,color:#52525b
Green is the allocation-free path; orange is where the input must become a
cue.Value.
- Schemix
- Table of Contents
- Features
- Install
- Quick Start
- Built-in Validators
- API Validation
- Schema Syntax
- Custom Functions & Methods
- Error Handling
- Custom Error Messages
- Schema Composition
- Schema Introspection
- FailMode
- Error Codes
- Bloblang Integration
- Registry Management
- Observability
- Convenience API
- Benchmarks
- Comparison
- License
| Category | Capabilities |
|---|---|
| Constraints | Types, regex, enums, ranges, nested structs, arrays [...{schema}], nullable null | type |
| Dynamic Rules | Bloblang expressions — return bool for validation, other types for computed values |
| Built-in Validators | 37+ methods: email, URL, UUID, IP, Luhn, JSON, Base64, mobile, length, range... |
| Custom Functions | Register your own functions/methods with Bloblang-compatible API (V1 & V2 styles) |
| Field Control | Priority groups, conditional required/skip, omit empty, fail-fast per field |
| Execution | Three FailModes — collect all / stop at first / priority-group isolation |
| Performance | Pre-compiled descriptors; scalar-only schemas validate in 382 ns with zero allocations — cue.Context.Encode is skipped entirely |
| Observability | MetricsRecorder hooks + OpenTelemetry tracing; ready-made schemixprom / schemixotel recorders |
| Error Handling | Structured codes, chain API (HasCode/ErrorsByCode/ErrorsByType), custom i18n formatter |
| Composition | Schema reuse via CUE definitions + NewFromValue, runtime introspection |
| Integration | Method & function forms for Benthos/Redpanda Connect pipelines |
| Thread Safety | Validator immutable after construction; Registry uses RWMutex |
go get github.com/mredencom/schemix@latestRequires: Go 1.25.0 or newer
v, err := schemix.New(`{
pan: =~"^[0-9]{16}$"
amount: int & >0
currency: "156" | "840"
// Built-in validators
luhn: bool @blob(this.pan.luhn_valid())
pan_check: bool @blob(this.pan.has_prefix("62") || this.pan.has_prefix("4"))
// Computed fields
card_brand: string @blob(if this.pan.has_prefix("62") { "UnionPay" } else { "Visa" })
fee: number @blob(if this.currency == "156" { 0 } else { (this.amount * 0.015).ceil() })
}`)
r := v.Process(map[string]any{
"pan": "4111111111111111", "amount": int64(10000), "currency": "840",
})
r.Valid // true
r.Output["card_brand"] // "Visa"
r.Output["fee"] // 150All methods are available automatically in @blob() expressions — no registration needed.
| Method | Usage | Description |
|---|---|---|
is_email() |
this.email.is_email() |
Email address format |
is_url() |
this.link.is_url() |
URL with scheme |
is_full_url() |
this.cb.is_full_url() |
Must start with http/https |
is_uuid() |
this.id.is_uuid() |
UUID any version |
is_uuid3/4/5() |
this.id.is_uuid4() |
Specific UUID version |
is_ip() |
this.host.is_ip() |
IPv4 or IPv6 |
is_ipv4() / is_ipv6() |
this.ip.is_ipv4() |
Specific IP version |
is_cidr() |
this.net.is_cidr() |
CIDR notation |
is_mac() |
this.mac.is_mac() |
MAC address |
is_dns_name() |
this.host.is_dns_name() |
DNS hostname |
is_json() |
this.body.is_json() |
Valid JSON string |
is_base64() |
this.token.is_base64() |
Base64 encoded |
is_hex() |
this.hash.is_hex() |
Hexadecimal string |
is_hex_color() |
this.color.is_hex_color() |
#RGB or #RRGGBB |
is_rgb_color() |
this.color.is_rgb_color() |
rgb(r,g,b) |
is_data_uri() |
this.img.is_data_uri() |
data:mime;base64,... |
is_latitude() |
this.lat.is_latitude() |
-90 to 90 |
is_longitude() |
this.lng.is_longitude() |
-180 to 180 |
is_isbn10/13() |
this.isbn.is_isbn13() |
ISBN format |
is_cn_mobile() |
this.phone.is_cn_mobile() |
China mobile (1xx) |
| Method | Usage | Description |
|---|---|---|
is_alpha() |
this.name.is_alpha() |
Letters only |
is_alpha_num() |
this.code.is_alpha_num() |
Letters + digits |
is_alpha_dash() |
this.slug.is_alpha_dash() |
Letters + digits + -_ |
is_numeric() |
this.pin.is_numeric() |
Digits only (0-9) |
is_number() |
this.val.is_number() |
Number string (±, decimal) |
is_ascii() |
this.s.is_ascii() |
ASCII only |
is_printable_ascii() |
this.s.is_printable_ascii() |
Printable ASCII (32-126) |
is_multibyte() |
this.s.is_multibyte() |
Contains multibyte chars |
| Method | Usage | Description |
|---|---|---|
not_blank() |
this.name.not_blank() |
Not empty/whitespace |
has_whitespace() |
this.s.has_whitespace() |
Contains whitespace |
| Method | Usage | Description |
|---|---|---|
len_between(min,max) |
this.s.len_between(min:3, max:20) |
String/slice/map length |
min_len(n) |
this.s.min_len(n: 3) |
Minimum length |
max_len(n) |
this.s.max_len(n: 100) |
Maximum length |
str_len(min,max) |
this.s.str_len(min:2, max:10) |
Rune count range |
between(min,max) |
this.age.between(min:0, max:150) |
Numeric range (inclusive) |
| Method | Usage | Description |
|---|---|---|
luhn_valid() |
this.pan.luhn_valid() |
Luhn checksum (card numbers) |
| Function | Usage | Description |
|---|---|---|
is_valid_date(d) |
is_valid_date(this.date) |
Parseable date string |
is_past_date(d) |
is_past_date(this.birthday) |
Date is in the past |
is_future_date(d) |
is_future_date(this.expiry) |
Date is in the future |
| Function | Usage | Description |
|---|---|---|
in_list(value, candidates) |
in_list(this.status, ["active","pending"]) |
Returns true if value is in the list |
Pre-compile at startup, validate per request with zero compilation overhead:
var userSchema = schemix.MustNew(`{
username: =~"^[a-zA-Z][a-zA-Z0-9_]{2,20}$"
email: string @blob(this.email.is_email())
password: string @blob(this.password.len_between(min: 8, max: 64))
age: int @blob(this.age.between(min: 13, max: 150))
role: "admin" | "user" | "guest"
}`, schemix.WithErrorFormatter(apiFormatter))
func CreateUser(w http.ResponseWriter, req *http.Request) {
var body map[string]any
json.NewDecoder(req.Body).Decode(&body)
r := userSchema.ProcessWithMode(body, schemix.FailAll)
if !r.Valid {
status := http.StatusBadRequest
if r.HasCode(schemix.CodeRequiredMissing) {
status = http.StatusUnprocessableEntity
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"error": "validation_failed",
"details": r.Errors,
})
return
}
// use r.Output ...
}| Syntax | Meaning | Example |
|---|---|---|
string / int / float / bool |
Type constraint | name: string |
& >=N & <=M |
Range | age: int & >=0 & <=150 |
=~"regex" |
Regex match | pan: =~"^[0-9]{16}$" |
"a" | "b" |
Enum | currency: "156" | "840" |
? |
Optional field | memo?: string |
null | type |
Nullable | memo: null | string |
{...} |
Nested struct | address: { city: string } |
[...{schema}] |
Array of schema | items: [...{id: string}] |
| Return Type | Behavior | Example |
|---|---|---|
bool = true |
Validation passes | @blob(this.amount > 0) |
bool = false |
Validation fails (→ E2B01) | @blob(this.age >= 18) |
| Non-bool | Computed value → Output | @blob(this.first + " " + this.last) |
| Comma-separated | AND — each independent | @blob(expr1, expr2) |
| Parameter | Type | Meaning |
|---|---|---|
priority=N |
int | Execution priority (lower = earlier) |
optional |
flag | No error if field missing |
conditional |
flag | Alias for optional; documents intent when paired with required_if |
skip_empty |
flag | Skip validation when empty |
fail_fast |
flag | Skip remaining rules on failure |
omit_if_skip |
flag | Remove from Output when skipped |
omit_empty |
flag | Remove from Output when empty |
required_if=expr |
bloblang | Conditionally required |
skip_if=expr |
bloblang | Conditionally skip |
Combined Example
{
payment_type: "credit" | "debit"
cvv?: string @meta(conditional, required_if=this.payment_type == "credit")
pan: =~"^[0-9]{16}$" @meta(priority=1)
luhn_check: bool @blob(this.pan.luhn_valid()) @meta(priority=2)
memo?: string @meta(optional, omit_empty)
fee?: number @meta(optional, skip_if=this.payment_type == "debit", omit_if_skip)
}
@meta(optional)and@meta(conditional)also relax the CUE layer. A field carrying either flag is treated as absent-tolerant even when the CUE syntax declares it required, socvv: string @meta(conditional, …)andcvv?: string @meta(conditional, …)behave identically: noE1M01is raised,required_ifgets to run, and a missingcvvon a credit payment reportsE3C01. Writing the?is preferred for clarity — it keeps the CUE layer and the@metalayer stating the same thing — but it is not required for correctness.
conditionalandoptionalare currently interchangeable —conditionalimpliesoptionaland no other behavior distinguishes them. Preferconditionalwhen the field's presence is governed byrequired_if, as documentation of intent.
Element structure is expressed with CUE; cross-element rules and per-element computation go on the array field itself:
{
items: [...{
product: =~"^.{3,50}$"
price: number & >0
qty: int & >=1
subtotal?: number // computed — must be optional, see below
}] @blob(
this.items.length() > 0, // rule
this.items.map_each(this.price * this.qty).sum() <= 100000, // rule
this.items.map_each(this.merge({"subtotal": this.price * this.qty})) // transform
)
}Comma-separated expressions are independent: those returning bool validate,
and a non-bool return replaces the array, which is how each element gets a
computed field.
Error paths differ by layer — prefer CUE for anything it can express, because only CUE reports the element index:
| Violated | Code | Path |
|---|---|---|
| CUE element constraint | E1R01 / E1T01 / E1F01 |
items[1].price |
@blob rule |
E2B01 |
items |
Useful Bloblang array methods: all(i -> …), any(i -> …), length(),
map_each(…), filter(i -> …), index(n), sum().
all()returns false for an empty array — unlike JavaScriptevery()or Pythonall(). Spell out the intent:this.items.length() > 0 && this.items.all(…)to require non-empty, orthis.items.length() == 0 || this.items.all(…)to allow empty.
Computed element fields must be declared optional (
subtotal?: number). CUE runs before@blob, so a required field that the rule is supposed to produce fails withE1M01before the rule ever executes.
Attributes inside an element schema are rejected.
items: [...{qty: int @blob(…)}]makesNew()return an error, because rules are compiled per field path and an element index is unknown until runtime — the attribute would be silently dropped and invalid data would pass. The error names the offending path and the supported rewrite.
Register custom validation logic using the same API as Bloblang — isolated per Validator:
// Function style: my_func(args...)
v, _ := schemix.New(schema, schemix.WithFunction("check_blacklist",
func(args ...any) (bloblang.Function, error) {
pan := args[0].(string)
return func() (any, error) {
return !isBlocked(pan), nil
}, nil
},
))
// Method style: this.field.my_method()
v, _ := schemix.New(schema, schemix.WithMethod("is_valid_bin",
func(v any) (any, error) {
return checkBIN(v.(string)), nil
},
))
// V2 style with typed parameters (PluginSpec + ParsedParams)
v, _ := schemix.New(schema, schemix.WithFunctionV2("calc_fee",
bloblang.NewPluginSpec().
Param(bloblang.NewInt64Param("amount")).
Param(bloblang.NewFloat64Param("rate")),
func(args *bloblang.ParsedParams) (bloblang.Function, error) {
amount, _ := args.GetInt64("amount")
rate, _ := args.GetFloat64("rate")
return func() (any, error) { return float64(amount) * rate, nil }, nil
},
))
// V2 method with params: this.field.method(param: value)
v, _ := schemix.New(schema, schemix.WithMethodV2("in_range",
bloblang.NewPluginSpec().
Param(bloblang.NewInt64Param("min")).
Param(bloblang.NewInt64Param("max")),
func(args *bloblang.ParsedParams) (bloblang.Method, error) {
min, _ := args.GetInt64("min")
max, _ := args.GetInt64("max")
return func(v any) (any, error) {
n := v.(int64)
return n >= min && n <= max, nil
}, nil
},
))For multiple custom functions, use FuncMap to build once and share:
funcs := schemix.NewFuncMap(
schemix.Func("check_blacklist", blacklistFn),
schemix.Func("calc_fee", feeFn),
schemix.Method("mask_pan", maskFn),
schemix.MethodV2("in_range", rangeSpec, rangeCtor),
)
// Share across validators
v1, _ := schemix.New(schema1, schemix.WithFuncMap(funcs))
v2, _ := schemix.New(schema2, schemix.WithFuncMap(funcs))Names are validated at construction time (must be snake_case: /^[a-z0-9]+(_[a-z0-9]+)*$/).
Built-in names are protected by default. Use WithOverrideMethod or WithOverrideFunc to
explicitly replace them:
// Override a specific built-in method
v, _ := schemix.New(schema,
schemix.WithOverrideMethod("is_email"),
schemix.WithMethod("is_email", myStrictEmailFn),
)
// Override a specific built-in function
v, _ := schemix.New(schema,
schemix.WithOverrideFunc("is_valid_date"),
schemix.WithFunction("is_valid_date", myDateFn),
)
// Override all — disable conflict checks entirely
v, _ := schemix.New(schema, schemix.WithOverrideAll(), schemix.WithFuncMap(myFuncs))Note: Function and Method are separate namespaces. Registering a Function named
is_emaildoes NOT conflict with the built-in Methodis_email.
r := v.Process(data)
r.Valid // bool
r.Err() // combined error (nil if valid)
r.FirstError() // *ValidationError
r.ErrorsByPath("pan") // []ValidationError
r.ErrorsByCode(schemix.CodeTypeMismatch) // []ValidationError
r.ErrorsByType("cue") // []ValidationError — filter by layer
r.HasCode(schemix.CodeBizRuleFailed) // bool — quick category check
r.HasErrorsAt("email") // bool — field-level check
r.ErrorMessages() // newline-joined stringEach ValidationError carries:
| Field | Meaning |
|---|---|
Code |
Stable error code (E1E01, E2B01, …) |
Path |
Field path — items[0].price, order.customer.age |
Type |
Layer that produced it — cue, bloblang, meta |
FieldType |
Schema type of the field — string, int, list… (empty when not applicable) |
Message |
Raw diagnostic: CUE/Bloblang wording, for logs |
Suggestion |
Closest valid value — enum violations only |
Enum errors name every accepted value and suggest the closest match:
r := v.Process(map[string]any{"currency": "USE"}) // schema: "CNY" | "USD" | "EUR"
r.Errors[0].Message // value "USE" not in enum ["CNY", "USD", "EUR"]
r.Errors[0].Suggestion // USD
r.Errors[0].FriendlyMessage() // currency must be one of ["CNY", "USD", "EUR"] — did you mean "USD"?
Suggestionis populated for enums only. A range or regex violation has no meaningful value to guess, and inventing one would mislead — the bound is already in the message (value 999 out of bound <=150).
Message and FriendlyMessage() are both always available, which covers the
two audiences without a mode switch:
log.Warn(e.Message) // raw: age: conflicting values "old" and int
render(e.FriendlyMessage()) // user-facing: age must be of type intFriendlyMessage() is derived from the structured fields (Code, Path,
FieldType, Suggestion), never returns an empty string, and keeps CUE
internals out of user-visible text.
For i18n or full control, provide an ErrorFormatter — it replaces Message:
v := schemix.MustNew(schema, schemix.WithErrorFormatter(
func(code schemix.ErrorCode, path, detail string) string {
return i18n.T("zh-CN", string(code), path)
},
))The formatter receives the error code, field path, and default detail message.
With no formatter, Message carries the raw CUE/Bloblang text.
Use NewFromValue to build validators from pre-compiled CUE values with shared definitions:
ctx := cuecontext.New()
schema := ctx.CompileString(`{
#PAN: =~"^[0-9]{16}$"
#Amount: int & >0
#Currency: "CNY" | "USD" | "EUR"
pan: #PAN
amount: #Amount
currency: #Currency
}`)
v, err := schemix.NewFromValue(schema)Definitions carry constraints only — attributes belong on the fields that reference them:
#PAN: =~"^[0-9]{16}$"
pan: #PAN @blob(this.pan.luhn_valid()) // ✅ rule on the field → error path "pan"#PAN: =~"^[0-9]{16}$" @blob(this.pan.luhn_valid()) // ❌ New() returns an errorA definition is a reusable template while a @blob expression binds to an
absolute path, so a definition referenced by two fields has no single path the
expression could resolve against. Such an attribute is never extracted, so
New() rejects it rather than validating less than the schema appears to.
Attributes on fields inside a struct definition are fine — a reference expands
them onto real paths:
#User: { age: int @blob(this.user.age >= 18) } // ✅ rule path becomes "user.age"
user: #UserInspect schema structure at runtime for documentation or UI generation:
fields := v.Fields() // []FieldInfo
for _, f := range fields {
fmt.Printf("%s: %s (optional=%v, blob=%v)\n", f.Path, f.Type, f.Optional, f.HasBlob)
for _, child := range f.Children {
fmt.Printf(" %s: %s\n", child.Path, child.Type)
}
}| Mode | Best For | Behavior |
|---|---|---|
FailAll |
Form validation | Collect all errors |
FailFast |
API gateway | Stop at first error |
FailPriority |
Layered validation | Collect CUE + Blob errors in the first failing priority group; skip higher groups |
r := v.ProcessWithMode(data, schemix.FailFast) // 1 error max
r := v.ProcessWithMode(data, schemix.FailAll) // all errors
r := v.ProcessWithMode(data, schemix.FailPriority) // first failing group onlyProcessing contracts: CUE and Blob rules in the same
FailPrioritygroup are both evaluated. Once that group fails, higher-priority-number groups do not run. Any invalid result hasOutput == nil. A non-bool@blob()result must satisfy its field schema or validation fails withE2T01.
Format: E{layer}{category}{seq}
| Constant | Code | Layer | Meaning |
|---|---|---|---|
CodeConfigError |
E0C01 | Config | Invalid configuration (e.g. undefined FailMode) |
CodeFormatMismatch |
E1F01 | CUE | Regex format mismatch |
CodeTypeMismatch |
E1T01 | CUE | Type error |
CodeEnumInvalid |
E1E01 | CUE | Invalid enum value |
CodeRangeViolation |
E1R01 | CUE | Range exceeded |
CodeRequiredMissing |
E1M01 | CUE | Required field missing |
CodeArrayElement |
E1A01 | CUE | Array element failed |
CodeCUEOther |
E1X01 | CUE | Other CUE error |
CodeBizRuleFailed |
E2B01 | Blob | Business rule false |
CodeExprExecError |
E2X01 | Blob | Expression error |
CodeBlobTypeMismatch |
E2T01 | Blob | @blob type contract violation |
CodeCondRequired |
E3C01 | Meta | Conditional required |
CodeMetaRuntimeError |
E3X01 | Meta | Meta expression runtime error |
reg := schemix.NewRegistry()
reg.Register("payment", cueSrc)
env := bloblang.NewEnvironment()
reg.RegisterAllTo(env) // scoped method + function formsMethod form — validates this:
let r = this.validate_schema(name: "payment", mode: "fast")
let r = this.process_schema(name: "payment", mode: "fast")Function form — dynamic data source:
let r = validate_schema(data: this.payload, name: "payment")
let r = process_schema(data: this.payload, name: "payment")validate_schema vs process_schema:
| Plugin | Returns | Use When |
|---|---|---|
validate_schema |
{valid, errors} |
You only need pass/fail + error details |
process_schema |
{valid, errors, output} |
You also need computed field values from @blob() |
reg := schemix.NewRegistry() // shared CUE context internally
reg.Register("user", cueSrc) // compile + store
reg.Has("user") // true
reg.List() // ["user"] — sorted
reg.Len() // 1
reg.Unregister("user") // remove
// Scoped Bloblang registration (recommended)
env := bloblang.NewEnvironment()
reg.RegisterAllTo(env) // register both method + function forms into env
reg.RegisterMethodsTo(env) // method form only into env
reg.RegisterFunctionsTo(env) // function form only into env
// Deprecated global registration (uses GlobalEnvironment; repeated registration returns an error)
reg.RegisterAll() // register both method + function forms
reg.RegisterMethods() // method form only: this.validate_schema(...) / this.process_schema(...)
reg.RegisterFunctions() // function form only: validate_schema(data: ...) / process_schema(data: ...)Metrics and tracing are opt-in. When neither is configured, every related code
path is skipped — Process and Validate incur zero overhead.
Implement MetricsRecorder and attach it with WithMetricsRecorder; WithName
labels metrics per schema:
v, _ := schemix.New(schema,
schemix.WithName("payment"),
schemix.WithMetricsRecorder(rec),
)| Method | Called |
|---|---|
ObserveValidation(d, valid, schemaName) |
once per Process / Validate |
ObserveLayerDuration(layer, d, schemaName) |
once per layer — cue, blob |
ObserveErrorCode(code, schemaName) |
once per validation error |
ObserveBlobExecution(path, d, success) |
once per @blob rule execution |
ObserveFastpathDecision(path, hit) |
once per field holding a fast constraint |
Implementations must be concurrency-safe and non-blocking — they run inline on every call. Buffer and batch asynchronously rather than doing network I/O.
Both live in their own module, so they add no dependencies to schemix itself:
go get github.com/mredencom/schemix/schemixprom # Prometheus
go get github.com/mredencom/schemix/schemixotel # OpenTelemetry metrics// Prometheus
rec, err := schemixprom.New(prometheus.DefaultRegisterer,
schemixprom.WithNamespace("myapp"))
// OpenTelemetry
rec, err := schemixotel.New(otel.GetMeterProvider())schemixprom registers {namespace}_schemix_*: validation_duration_seconds,
validations_total, errors_total, blob_duration_seconds,
blob_executions_total, layer_duration_seconds, fastpath_decisions_total.
schemixotel emits the same set as schemix.validation.duration /
.total, schemix.layer.duration, schemix.blob.duration / .total,
schemix.error.total, schemix.fastpath.total.
Spans are created only on the context-aware methods:
v, _ := schemix.New(schema, schemix.WithTracerProvider(otel.GetTracerProvider()))
r := v.ProcessContext(ctx, data) // root span + schemix.cue / schemix.blob childrenThe root span carries schemix.schema_name, schemix.fail_mode,
schemix.valid, schemix.error_count and schemix.field_count, and records a
validation_error event per error (capped at 20 per span).
// Construction
v := schemix.MustNew(cueSrc) // panic on error
v, _ := schemix.NewWithContext(ctx, src) // shared CUE context
v, _ := schemix.NewFromValue(cueValue) // from pre-compiled CUE value
// Options — custom functions
schemix.WithErrorFormatter(fn) // custom error messages
schemix.WithFunction(name, ctor) // custom function (V1)
schemix.WithFunctionV2(name, spec, ctor) // custom function (V2)
schemix.WithMethod(name, fn) // custom method (V1)
schemix.WithMethodV2(name, spec, ctor) // custom method (V2)
schemix.WithFuncMap(funcs) // inject reusable FuncMap
schemix.WithMaxSchemaDepth(32) // bound construction-time schema recursion
// Options — override built-in validators
schemix.WithOverrideMethod(names...) // allow overriding specific built-in methods
schemix.WithOverrideFunc(names...) // allow overriding specific built-in functions
schemix.WithOverrideAll() // disable all conflict checks
// FuncMap construction
funcs := schemix.NewFuncMap(opts...) // build reusable collection
schemix.Func(name, ctor) // FuncMap entry: function (V1)
schemix.FuncV2(name, spec, ctor) // FuncMap entry: function (V2)
schemix.Method(name, fn) // FuncMap entry: method (V1)
schemix.MethodV2(name, spec, ctor) // FuncMap entry: method (V2)
funcs.Err() // first validation error (nil if valid)
// Validation (fast path — no Output allocation)
valid, errs := v.Validate(data)
// Processing (validation + computed fields)
r := v.Process(data)
r := v.ProcessWithMode(data, schemix.FailFast)
// Introspection
fields := v.Fields() // []FieldInfoApple M4, Go 1.25.11 — 6 fields (3 CUE + 3 @blob):
| Operation | Time | Memory | Allocs |
|---|---|---|---|
New (compile) |
430 µs | 796 KiB | 22366 |
Process (valid) |
4.67 µs | 11.90 KiB | 86 |
Process (invalid) |
5.59 µs | 13.14 KiB | 102 |
Process (nested) |
37.35 µs | 45.86 KiB | 492 |
Validate (no output) |
4.82 µs | 11.54 KiB | 82 |
Process (parallel, 10 cores) |
4.20 µs | 11.90 KiB | 86 |
ValidateFields (fast path) |
146.5 ns | 0 B | 0 |
Registry.Get |
6.25 ns | 0 B | 0 |
Simple scalar fields use a Go-native fast path that bypasses CUE entirely, achieving about 175x speedup over the CUE legacy path (146.5ns vs 25.62µs).
cue.Context.Encodeis lazy: a schema whose fields are all served by the fast path never converts the input into acue.Valueat all. That is exactly the 39 allocations missing from every row above compared to earlier releases (Process125 → 86,Validate121 → 82). Nested and array schemas still require the encode, which is whyProcess (nested)is unchanged at 492.Pull requests also run base and head benchmarks on the same CI runner. A statistically significant regression above 5% fails the benchmark gate.
All engines validate the same five constraints (pan 16 digits, amount
int > 0, currency enum, age 0..150, email format), each in that library's
idiomatic best form. An equivalence test
asserts all six reach the identical verdict before any number is published.
Apple M4, Go 1.25.11, benchstat medians. Time / allocations per operation:
| Scenario | schemix | go-playground/validator | ozzo-validation | jsonschema v6 | raw CUE API |
|---|---|---|---|---|---|
| Scalar, valid | 382 ns · 0 | 784 ns · 6 | 1.72 µs · 37 | 1.87 µs · 56 | 12.86 µs · 186 |
| Scalar, invalid | 1.06 µs · 15 | 733 ns · 25 | 2.05 µs · 49 | 2.13 µs · 81 | 16.30 µs · 301 |
| Parallel, 10 cores | ~100 ns · 0 | 247 ns · 6 | — | 785 ns · 56 | — |
| JSON bytes, end-to-end | 1.69 µs · 31 | 1.50 µs · 14 | 2.52 µs · 45 | 2.82 µs · 80 | — |
| Nested + 3-item array | 27.35 µs · 432 | 1.05 µs · 10 | — | 4.35 µs · 133 | — |
With one @blob() rule |
6.44 µs · 127 | not supported | not supported | not supported | — |
| Compile (once at startup) | 43.97 µs | 10.11 µs | — | 65.97 µs | — |
Capabilities, where the difference is structural rather than a matter of nanoseconds:
| schemix | validator | ozzo | JSON Schema | |
|---|---|---|---|---|
| Schema is hot-loadable text, not compiled Go | ✅ | ❌ | ❌ | ✅ |
| Computed / derived output fields | ✅ | ❌ | ❌ | ❌ |
| Dynamic expression language | ✅ Bloblang | ✅ Go | if/then |
|
| Stable structured error codes | ✅ | ❌ | ❌ | ❌ |
| Priority-grouped failure isolation | ✅ | ❌ | ❌ | ❌ |
| Metrics + OTel tracing hooks | ✅ | ❌ | ❌ | ❌ |
| Cross-language portability | ❌ | ❌ | ❌ | ✅ |
Scalar-only schemas validate allocation-free — 2.1x faster than struct-tag
reflection and 34x faster than driving CUE directly, because cue.Context.Encode
is skipped entirely when every field is served by the Go-native fast path.
Two honest boundaries on that headline:
- Add one
@blob()rule, a nested struct, or an array and the input must be encoded into acue.Value— cost jumps by an order of magnitude. - Arrays are the weak spot: the fast path has no list descriptor, so the whole
list goes to
cue.Value.Unifyat ~6.3 µs per element. Validating large collections element-by-element against a per-elementValidatoris measured 62-82x faster.
What schemix offers that raw throughput does not cover: the schema is
hot-loadable text rather than compiled Go, plus computed fields (@blob()),
dynamic expressions, structured error codes, priority-grouped failure isolation,
metrics/OTel hooks, and a Benthos pipeline plugin. If you need none of those and
the shape is a compile-time Go struct, go-playground/validator is the leaner
choice; if the schema must be portable across languages, use JSON Schema.
Full per-scenario tables, the array breakdown, and reproduction steps: benchmarks/comparison.