Skip to content

Codec — declare once

See also: codex package on pkg.go.dev

A Codec[T] is the single source of truth for a type. It bundles three concerns in one value:

type Codec[T any] struct {
    Schema  schema.Schema          // shape + constraints as data
    Encode  func(T) (any, error)   // T → intermediate (e.g. map[string]any for JSON)
    Decode  func(any) (T, error)   // intermediate → T, validates constraints
}

Why one value?

Traditional approaches scatter the same information across multiple files: - A struct with JSON tags for encoding - A validation library for constraints - A Swagger annotation comment for the schema

go-codex collapses all three into a single Codec[T] that you define once and pass around. Every format (JSON, YAML, OpenAPI, AsyncAPI) is derived from the same definition automatically.

Primitive codecs

Constructor Go type JSON wire Schema
codex.Int() int number {type:integer}
codex.Int32() int32 number {type:integer,format:int32}
codex.Int64() int64 number {type:integer,format:int64}
codex.Uint() uint number {type:integer,minimum:0}
codex.Uint64() uint64 number {type:integer,minimum:0}
codex.Float32() float32 number {type:number,format:float}
codex.Float64() float64 number {type:number}
codex.String() string string {type:string}
codex.Bool() bool boolean {type:boolean}
codex.Bytes() []byte base64 string {type:string,format:byte}
codex.Time() time.Time RFC 3339 string {type:string,format:date-time}
codex.Date() time.Time YYYY-MM-DD {type:string,format:date}
codex.Duration() time.Duration duration string {type:string,format:duration}
codex.HexColor() codex.Color hex string (#RGB/#RRGGBB/#RGBA/#RRGGBBAA) {type:string,pattern:...}
codex.Nullable(inner) *T value or null inner schema + nullable:true
codex.SliceOf(elem) []T array {type:array,items:{...}}
codex.StringMap(value) map[string]V object {type:object,additionalProperties:{...}}
codex.Map(keyCodec, valueCodec) map[K]V object {type:object,propertyNames:{...},additionalProperties:{...}}
codex.Struct[T](fields...) any struct object {type:object,properties:{...}}
codex.TaggedUnion[T](tag, variants...) any interface object {oneOf:[...],discriminator:{...}}
codex.UntaggedUnion[T](which, variants...) any interface object {oneOf:[...]}
codex.Either2(ca, cb) Either[A,B] value {oneOf:[schemaA,schemaB]}
codex.StringOrInt64() (and StringOrInt/StringOrInt32/StringOrUint/StringOrUint64/StringOrFloat32/StringOrFloat64) Either[string,int64] (etc.) string or number {oneOf:[{type:string},{type:integer,...}]}
codex.Any() any any {}
codex.Pure(value) T fixed wire value {enum:[value]}
codex.Eq(base, value) T comparable validated by base base schema + {enum:[value]}

Composition at a glance

The following constructors accept another codec as an argument, letting you compose types of arbitrary depth and shape:

Composer Use for Example
codex.Struct[T](fields...) object with named, typed fields Struct[Order](RequiredField("customer", customerCodec, ...))
codex.SliceOf(elem) homogeneous array SliceOf(lineItemCodec)Codec[[]LineItem]
codex.StringMap(value) map[string]V — string keys, typed values StringMap(codex.Int())Codec[map[string]int]
codex.Map(keyCodec, valCodec) map[K]V — both key and value validated Map(sensorIDCodec, codex.Float64())
codex.Nullable(inner) optional pointer *T (present or nil) Nullable(codex.String())Codec[*string]
codex.TaggedUnion[T](tag, variants...) discriminated union — tag field selects variant TaggedUnion[Shape]("type", circleVariant, rectVariant)
codex.UntaggedUnion[T](which, variants...) structural union — first-match decode UntaggedUnion[Shape](selector, variants...)
codex.Either2(ca, cb) two-branch sum — Either[A, B] Either2(codex.String(), dbConfigCodec)
codex.StringOrInt64() (family) value is a string OR a number — Either[string, int64] (etc.) StringOrInt64() for a Docker/IoT-Edge-style env var value, "5" or 5
codex.EntrySlice(keyCodec, valCodec, merge, split) object → []R, key merged into element EntrySlice(containerKeyCodec, moduleCodec, merge, split)
codex.Nullable(SliceOf(inner)) optional array compose freely to any depth

Any of these can be used as the codec argument of RequiredField / OptionalField, so nesting is unlimited: StructSliceOf(Struct)Nullable(Struct) → …

// Nullable pointer field
var noteCodec = codex.Nullable(codex.String())  // Codec[*string]
note, _ := noteCodec.Decode(nil)                // → (*string)(nil)
s := "hello"
enc, _ := noteCodec.Encode(&s)                  // → "hello"

// Time
var createdAtCodec = codex.Time()
enc, _ = createdAtCodec.Encode(time.Now())     // → "2024-06-15T12:00:00Z"

// StringMap
var tagsCodec = codex.StringMap(codex.String())
enc, _ = tagsCodec.Encode(map[string]string{"env": "prod"})

// Map[K, V] — keys validated via a key codec.
// Key codec must encode K to a string (JSON/YAML require string map keys).
// The schema emits "propertyNames" for the key constraint.
var sensorIDCodec = codex.String().
    Refine(validate.Pattern(regexp.MustCompile(`^[a-z]+-\d+$`))).
    WithTitle("SensorID")
var sensorsCodec = codex.Map[string, float64](sensorIDCodec, codex.Float64())
// Schema: {type:object, propertyNames:{type:string,title:"SensorID",pattern:"..."}, additionalProperties:{type:number}}
_, _ = sensorsCodec.Encode(map[string]float64{"temp-01": 22.5}) // ok
_, err := sensorsCodec.Encode(map[string]float64{"INVALID": 22.5})
// → KeyError{Key:"INVALID", Err: constraint failed (pattern)}
_ = err

// Any — opaque passthrough, no type enforcement
var rawCodec = codex.Any()
val, _ := rawCodec.Decode(map[string]any{"x": 1}) // passes through unchanged
_ = val

Struct codecs

var UserCodec = codex.Struct[User](
    codex.RequiredField("name", nameCodec, get, set),
    codex.OptionalField("bio",  codex.String(), get, set),
)

get/set are a getter (func(T) F) and a setter (func(*T, F)) for that one field — how RequiredField/OptionalField/DefaultField read a value out of your struct (encode) and write a decoded value back into it (decode). Go has no reflection-free way to do this automatically, so plain closures are the mechanism; they are not go-codex-internal plumbing, they're what you write at every call site:

codex.RequiredField("name", nameCodec,
    func(u User) string { return u.Name },
    func(u *User, v string) { u.Name = v },
)

They reach nested sub-structs the same way (func(u User) string { return u.Meta.Region }). See "Field factory functions" below for how to avoid repeating them when several struct types share the same field.

Missing fields on decode

When a field key is absent from the incoming object, the codec follows this decision tree:

field key absent from wire object?
├─ DefaultField  → apply declared default, continue
├─ RequiredField → return ErrMissingField (decode fails)
└─ OptionalField → do nothing; Go zero value remains ("", 0, nil, …)

RequiredField — missing key → ErrMissingField sentinel, surfaced as ValidationError{Field: "name", Err: ErrMissingField} inside ValidationErrors. Check with errors.Is(err, codex.ErrMissingField).

OptionalField — missing key → the Go field retains its zero value. The set function is never called. There is no distinction between "key absent" and "key present with zero value" at the Go struct level — if you need that, use Nullable:

// "note" absent  → Note == nil  (key was not in the object)
// "note": null   → Note == nil  (key was present, value was null)
// "note": "hi"   → Note == &"hi"
codex.OptionalField("note", codex.Nullable(codex.String()),
    func(u User) *string { return u.Note },
    func(u *User, v *string) { u.Note = v },
)

DefaultField — missing key → declared default applied via set. The default also appears in the generated OpenAPI/AsyncAPI schema as "default": <value>. A zero-value default is valid — DefaultField uses a pointer internally to distinguish "no default" from "default is zero":

codex.DefaultField("log_level",
    codex.String().Refine(validate.OneOf("debug", "info", "warn", "error")),
    "info",   // applied when key absent; emitted as "default" in schema
    func(c Config) string { return c.LogLevel },
    func(c *Config, v string) { c.LogLevel = v },
)

Missing fields — summary

Constructor Field absent (decode) Field absent (encode) Schema
RequiredField ErrMissingField → decode fails always encoded required: [field]
OptionalField Go zero value ("", 0, nil, …) always encoded field not in required
DefaultField declared default applied always encoded field not in required; default: <value>
OptionalField + Nullable nil pointer (type-safe absent) encoded as null field not in required; nullable: true

Encode note: there is no "omit if zero" logic on encode — every field is always written to the output object. If you want a field to be absent rather than null on encode, handle that outside the codec.

Rejecting unknown keys — StrictStruct

Struct is forward-compatible by default: any input key not matching a declared field is silently ignored on decode, and the generated schema leaves additionalProperties unset (JSON Schema default: extra properties allowed). Use codex.StrictStruct — same signature as Struct, same RequiredField/OptionalField/DefaultField vocabulary — to reject unknown keys instead (catches typo'd field names):

var StrictUserCodec = codex.StrictStruct[User](
    codex.RequiredField("name", nameCodec, get, set),
    codex.OptionalField("bio",  codex.String(), get, set),
)

_, err := StrictUserCodec.Decode(map[string]any{"name": "Alice", "boi": "typo"})
// err: field "boi": unknown field (codex.ErrUnknownField) — errors.Is-navigable
  • Sets Schema.AdditionalProperties = false (JSON Schema additionalProperties: false) — Struct never sets this field at all.
  • Unknown-key errors are collected ALONGSIDE normal per-field errors (missing required fields, constraint failures) in one ValidationErrors — a request with both a missing required field and a typo'd key reports both, not just one.
  • Not viral/recursive across nesting — a nested field declared with plain Struct stays non-strict even when the OUTER struct uses StrictStruct. Opt in explicitly at each nesting level, exactly like Required/Optional/Default are declared independently per level (see Nested structs).
  • Encode is unchanged from Struct — "unknown field" is a decode-only (external input) concept.

Reusing Field declarations for path/topic/header/query vars

This is the mechanism behind a general library design principle: one struct, one call (see API Contracts — Design principle) — a caller on either side of a boundary should be able to do an entire encode-or-decode direction with one struct value and one call, not manual map assembly. RequiredField/OptionalField/DefaultField aren't just for JSON object keys — the same declarations work for any string-keyed source: HTTP path/query/header/cookie parameters, MQTT/event topic variables, and file path segments. codex.DecodeVars/EncodeVars decode/encode a map[string]string (instead of a JSON map[string]any) using those exact same Field declarations — no new declaration API to learn:

var idField = codex.RequiredField("id", codex.String().Refine(validate.UUID),
    func(r GetUserReq) string { return r.ID },
    func(r *GetUserReq, v string) { r.ID = v })

// Decode: partial merge into an existing struct (only "id" is touched).
var req GetUserReq
err := codex.DecodeVars(&req, map[string]string{"id": r.PathValue("id")}, idField)

// Encode: extract field values into a vars map (replaces hand-written
// varsFor func(T) map[string]string closures used by adapter constructors).
vars, err := codex.EncodeVars(req, idField)

A field's codec must accept a string on Decode — plain codex.String() for string fields, or codex.MapCodecSafe(codex.String()..., parse, format) for typed fields like int/time.Time (vars are always string-valued at the wire level: path segments, topic segments, header/query/cookie values).

Per-boundary sugar: rest.NewPathParam[T]/NewRequiredQueryParam[T]/ NewOptionalQueryParam[T] (+ Header/Cookie equivalents) and ports.NewFilePathParam[T] declare BOTH the boundary's spec Param (for OpenAPI/AsyncAPI generation, unchanged) AND a merge field in ONE call — see Ports feature — File and REST API feature for the full per-boundary API. The plain PathParam/QueryParam/FilePathParam struct literals remain available as the low-level, validate-only alternative.

A struct can mix BOTH sources at once — some fields decoded from the body, others merged from vars — as long as the body codec and the merge fields declare different field names (RouteHandle.DecodeMerged decodes the body first, then merges vars into the same value, touching only the declared merge fields). See REST API — Mixing body fields and merged params.

The SAME mechanism applies in the RESPONSE direction — rest.NewRequiredResponseHeaderParam[Resp]/NewOptionalResponseHeaderParam[Resp] (+ Cookie equivalents) declare a response header/cookie merge field on Resp: the server sets it automatically from the returned struct's field, and the client merges the HTTP response back into the same field automatically. On top of the four merge-field roles (path/query/header/cookie), nethttp.CallHandle is the single-call client convenience that derives every request-side map from ONE struct automatically — this is the concrete "one struct, one call" experience end to end. See REST API — Response merge fields and REST API — One-line client calls.

This also means the pattern is neither JSON-specific nor flat-struct-specific: get/set on RequiredField/OptionalField (and every REST merge-field constructor built on them) are plain Go closures, not reflection over a struct's direct fields — a closure reaching into a NESTED sub-struct (func(r Req) string { return r.Meta.X }) works exactly like a top-level field, with zero framework changes. And since body decode/encode is completely orthogonal to var-merge, ANY format.Format[T] — JSON, YAML, TOML, format.Gob, format.Binary, or a custom format.NewTyped — composes with merge fields unchanged. See REST API — Nested structs & binary body formats and examples/rest-nested-binary for the full runnable version.

Constraints with Refine

var EmailCodec = codex.String().Refine(validate.Email)
// Schema gains: format: email
// Decode rejects invalid emails at runtime
// Encode validates before serialising

Constraints run symmetrically — on both Encode and Decode — ensuring the codec is the single source of truth for validity.

Stacking and combining constraints

.Refine(...) is variadic and chainable. Every call wraps the previous codec, building a validation chain:

// All three are equivalent — same chain, same execution order:
codex.String().Refine(A, B, C)
codex.String().Refine(A).Refine(B).Refine(C)
codex.String().Refine(A).Refine(B, C)

Constraints run in declaration order. The first failure stops the chain — later constraints do not run.

Mix built-in and custom constraints freely:

var containerNameCodec = codex.String().
    Refine(validate.NonEmptyString).           // built-in: non-empty
    Refine(validate.MaxLen(63)).               // built-in: DNS label max length
    Refine(codex.Constraint[string]{           // custom: no spaces/underscores/slashes
        Name:  "container-name",
        Check: func(v string) bool { return !strings.ContainsAny(v, " _/") },
        Message: func(v string) string {
            return fmt.Sprintf("container name %q must not contain spaces, underscores, or slashes", v)
        },
    }).
    Refine(codex.Constraint[string]{           // custom: must start with a letter
        Name:  "starts-with-letter",
        Check: func(v string) bool { return len(v) > 0 && unicode.IsLetter(rune(v[0])) },
        Message: func(v string) string {
            return fmt.Sprintf("container name %q must start with a letter", v)
        },
    })

Order matters: put cheaper constraints first (length, empty check) so they short-circuit before expensive ones (regex, external lookup).

Schema annotation from custom constraints

Set the optional Constraint.Schema function to propagate constraint metadata into the generated OpenAPI/AsyncAPI schema:

var slugCodec = codex.String().
    Refine(validate.NonEmptyString).
    Refine(codex.Constraint[string]{
        Name:  "slug",
        Check: func(v string) bool { return slugPattern.MatchString(v) },
        Message: func(v string) string {
            return fmt.Sprintf("%q is not a valid slug (lowercase letters, digits, hyphens only)", v)
        },
        Schema: func(s schema.Schema) schema.Schema {
            s.Pattern = `^[a-z0-9-]+$`   // emitted in the OpenAPI/AsyncAPI spec
            return s
        },
    })
// Schema: {type: string, minLength: 1, pattern: "^[a-z0-9-]+$"}

Each Constraint.Schema function receives the schema produced by all previous constraints and returns the augmented version — they compose without interfering.

Built-in constraints in the validate package already set Schema where appropriate (e.g. validate.Email sets format: email, validate.RangeInt sets minimum/maximum). Custom constraints without a Schema function simply add runtime validation without changing the spec.

Whole-struct (cross-field) constraints

.Refine(...) isn't limited to scalar codecs — codex.Struct[T](...) returns a plain Codec[T], so the same mechanism validates INVARIANTS THAT SPAN MULTIPLE FIELDS, something no single per-field Constraint can see:

type DateRange struct {
    Start, End time.Time
}

var dateRangeCodec = codex.Struct[DateRange](
    codex.RequiredField("start", codex.Time(), func(d DateRange) time.Time { return d.Start }, func(d *DateRange, v time.Time) { d.Start = v }),
    codex.RequiredField("end",   codex.Time(), func(d DateRange) time.Time { return d.End },   func(d *DateRange, v time.Time) { d.End = v }),
).Refine(codex.Constraint[DateRange]{
    Name:  "start-before-end",
    Check: func(d DateRange) bool { return d.Start.Before(d.End) },
    Message: func(d DateRange) string {
        return fmt.Sprintf("start (%s) must be before end (%s)", d.Start, d.End)
    },
})
  • The whole-struct Constraint[DateRange]'s Check receives the FULLY DECODED DateRange — it runs AFTER every per-field Required/Codec/nested-struct check has already succeeded, so a missing/invalid individual field is reported first (per-field ValidationErrors), and the cross-field constraint only runs once the struct is otherwise well-formed.
  • Symmetric on Encode too — an in-memory DateRange{Start: later, End: earlier} fails to encode, exactly like any other Refined codec.
  • Use this for "at least one of A or B must be set", "field X only valid when field Y is a certain value", range checks across two fields, and similar invariants that a single RequiredField/OptionalField declaration cannot express.

Composition — shared field codecs

Define field codecs once and reuse across struct codecs:

var emailFieldCodec = codex.String().Refine(validate.Email).WithDescription("Email address.")

var UserCodec    = codex.Struct[User](   codex.RequiredField("email", emailFieldCodec, ...), ...)
var ProfileCodec = codex.Struct[Profile](codex.RequiredField("email", emailFieldCodec, ...), ...)
// Both carry the same constraint and description — no duplication.

Field factory functions — reusing field groups across structs

When the same field (same name, same codec, same validation rules) appears in multiple structs, combine shared field codecs with a field factory function. A factory function bakes in the field name and codec rules and accepts only the getter/setter — the one thing that is unavoidably struct-specific.

import "time"

// ── Shared field codecs ───────────────────────────────────────────────────────

var idCodec = codex.String().Refine(validate.UUID).
    WithDescription("Unique identifier (UUID v4).")

var createdAtCodec = codex.Time().
    WithDescription("Creation timestamp (RFC 3339).")

// ── Field factory functions ───────────────────────────────────────────────────
// The type parameter T makes each factory work for any struct that has the
// appropriate field, without repeating the field name or validation rules.

func IDField[T any](get func(T) string, set func(*T, string)) codex.Field[T, string] {
    return codex.RequiredField("id", idCodec, get, set)
}

func CreatedAtField[T any](get func(T) time.Time, set func(*T, time.Time)) codex.Field[T, time.Time] {
    return codex.RequiredField("created_at", createdAtCodec, get, set)
}

// ── Structs using Go embedding for DRY field access ──────────────────────────

type AuditBase struct {
    ID        string
    CreatedAt time.Time
}

type User struct {
    AuditBase               // embedded — promotes ID and CreatedAt
    Name  string
    Email string
}

type Device struct {
    AuditBase               // same embedding
    Hostname string
    IPAddr   string
}

// ── Codecs — each struct provides only its own getters/setters ───────────────

var UserCodec = codex.Struct[User](
    IDField(
        func(u User) string  { return u.ID },
        func(u *User, v string) { u.ID = v },
    ),
    CreatedAtField(
        func(u User) time.Time  { return u.CreatedAt },
        func(u *User, v time.Time) { u.CreatedAt = v },
    ),
    codex.RequiredField("name",
        codex.String().Refine(validate.NonEmptyString),
        func(u User) string { return u.Name },
        func(u *User, v string) { u.Name = v },
    ),
    codex.RequiredField("email",
        codex.String().Refine(validate.Email),
        func(u User) string { return u.Email },
        func(u *User, v string) { u.Email = v },
    ),
)

var DeviceCodec = codex.Struct[Device](
    IDField(
        func(d Device) string  { return d.ID },
        func(d *Device, v string) { d.ID = v },
    ),
    CreatedAtField(
        func(d Device) time.Time  { return d.CreatedAt },
        func(d *Device, v time.Time) { d.CreatedAt = v },
    ),
    codex.RequiredField("hostname",
        codex.String().Refine(validate.Hostname),
        func(d Device) string { return d.Hostname },
        func(d *Device, v string) { d.Hostname = v },
    ),
    // ...
)

What is shared: the field name ("id"), the codec (idCodec), and all its constraints and schema metadata. A change to idCodec propagates to every struct that uses IDField — one declaration, zero drift.

What is not shared: the getter/setter functions — they are unavoidably struct-specific because Go's type system cannot derive func(Device) string from func(User) string automatically.

Why not a library feature: Go does not have inheritance, and reflecting on struct fields to auto-derive getters/setters would violate the no-reflection, no-struct-tags design principle. Field factory functions achieve the same result with full type safety and zero magic.

Nested structs

A field codec can be any Codec[F] — including another Struct[...] codec. Nesting is unlimited and composes with SliceOf, Nullable, and StringMap:

type Address struct { Street, City, Country string }
type Customer struct { Name, Email string }
type LineItem struct { Product string; Quantity int; Price float64 }
type Order struct {
    ID             string
    Customer       Customer
    Shipping       Address
    BillingAddress Address // same Address type/codec as Shipping — see below
    Items          []LineItem
    Tags           map[string]string
    Note           *string
}

var addressCodec = codex.Struct[Address](
    codex.RequiredField("street",  codex.String().Refine(validate.NonEmptyString), ...),
    codex.RequiredField("city",    codex.String().Refine(validate.NonEmptyString), ...),
    codex.RequiredField("country", codex.String().Refine(validate.NonEmptyString), ...),
)

var lineItemCodec = codex.Struct[LineItem](
    codex.RequiredField("product",  codex.String(), ...),
    codex.RequiredField("quantity", codex.Int().Refine(validate.PositiveInt), ...),
    codex.RequiredField("price",    codex.Float64().Refine(validate.PositiveFloat), ...),
)

var orderCodec = codex.Struct[Order](
    codex.RequiredField("id",       codex.String(), ...),
    codex.RequiredField("customer", customerCodec,              ...),  // nested Struct, required
    codex.RequiredField("shipping", addressCodec,               ...),  // nested Struct, required
    codex.OptionalField("billingAddress", addressCodec,         ...),  // SAME nested Struct, optional
    codex.RequiredField("items",    codex.SliceOf(lineItemCodec).Refine(validate.NonEmptySlice[LineItem](), validate.MaxItems[LineItem](20)), ...), // slice of structs, 1-20 items
    codex.OptionalField("tags",     codex.StringMap(codex.String()), ...), // map
    codex.OptionalField("note",     codex.Nullable(codex.String()),  ...), // optional
)

Required vs optional is orthogonal to whether a field's codec is a nested Struct or a scalar. RequiredField/OptionalField/DefaultField only decide PRESENCE — they never inspect what codec they wrap. shipping and billingAddress above use the exact same addressCodec; only RequiredField vs OptionalField differs:

  • Absent + Required → decode fails with ErrMissingField for that field name.
  • Absent + Optional → the nested field is silently left at its Go zero value (Address{}) — decode succeeds.
  • Present (either case) → decodes through the nested struct's OWN codec, which enforces its OWN required/optional/default rules completely independently. A present-but-incomplete billingAddress (e.g. missing country) still fails — declaring the OUTER field Optional never weakens the INNER struct's own validation, it only controls whether the key itself must exist.

What you get for free: - Encode/decode recurses automatically — order.Customer is a Go struct, the JSON "customer" is an object. - Validation cascades — a constraint failure on customer.email surfaces as a nested ValidationErrors: the top-level error names "customer", and unwrapping it one level (errors.As into codex.ValidationErrors) yields the inner error naming "email" (message reads field customer: field email: ... — nested attribution, not a flattened dotted-path string). - Schema generation — OrderCodec.Schema produces a nested $object with inline Customer/Address schemas, and each nesting level's Required array is independent: billingAddress is absent from the OUTER Required list, but its own embedded schema still declares ["street","city","country"] as required. - Encoding a never-populated Optional nested struct still emits the key with its zero value — codex has no "omit empty" semantics; Required/Optional/Default only affect DECODE and the generated schema's Required array, never what ENCODE writes.

See examples/order for a complete runnable demo with all five nesting patterns, including both a required (shipping) and an optional (billingAddress) nested struct built from the identical codec.

Slices — array-level constraints

codex.SliceOf(elem) returns a plain Codec[[]T], so it composes with .Refine(...) exactly like any scalar codec — required/optional at the FIELD level is orthogonal to constraints on the array's LENGTH or CONTENTS:

var itemsCodec = codex.SliceOf(lineItemCodec).
    Refine(validate.NonEmptySlice[LineItem]()).  // at least 1 element
    Refine(validate.MaxItems[LineItem](20))      // at most 20 elements
// Schema: {type: array, items: {...}, minItems: 1, maxItems: 20}

Built-in validate constructors for slices (all generic, in validate/slice.go):

Constructor Enforces Schema keyword
validate.MinItems[T](n) at least n elements minItems
validate.MaxItems[T](n) at most n elements maxItems
validate.NonEmptySlice[T]() at least 1 element (equivalent to MinItems[T](1)) minItems: 1
validate.UniqueItems[T comparable]() no duplicate elements (Go == equality, map[T]struct{} dedup) uniqueItems

UniqueItems requires T comparable — narrower than the other three's any — since it needs Go equality to detect duplicates. For element types containing slices/maps/funcs (not comparable), write a custom codex.Constraint[[]T] using reflect.DeepEqual or a domain-specific key extractor instead.

These constructors are functions, not package-level vars like validate.NonEmptyString — Go has no generic package-level variables, so the element type must be supplied at the call site (validate.MinItems[LineItem](1), not validate.MinItems(1)).

Array-level required/optional (whether the KEY itself must be present) is controlled the same way as any other field — RequiredField/OptionalField/DefaultField wrapping a SliceOf(...) codec, exactly as shown for items above. Absent + Optional leaves the field at its Go zero value (nil slice); absent + Required fails with ErrMissingField; present (either case) runs the length/uniqueness constraints declared via .Refine(...).

Maps — size constraints

codex.Map[K,V]/codex.StringMap[V] return plain Codec[map[K]V] codecs, so entry-count constraints compose with .Refine(...) exactly like slice-length constraints:

var tagsCodec = codex.StringMap(codex.String()).
    Refine(validate.MaxProperties[string, string](5))  // at most 5 entries
// Schema: {type: object, additionalProperties: {...}, maxProperties: 5}

Built-in validate constructors for maps (all generic, in validate/map.go):

Constructor Enforces Schema keyword
validate.MinProperties[K, V](n) at least n entries minProperties
validate.MaxProperties[K, V](n) at most n entries maxProperties
validate.NonEmptyMap[K, V]() at least 1 entry (equivalent to MinProperties[K,V](1)) minProperties: 1

These mirror validate/slice.go's array constructors exactly, one level up (entries instead of elements). Map KEY constraints (pattern, format, enum, length) are a SEPARATE, already-fully -supported concern: pass a .Refine(...)-composed Codec[K] as Map's keyCodec argument — it renders into the schema's propertyNames and is validated per-key on every decode. Do not confuse the two: MinProperties/MaxProperties constrain how MANY entries the map has; key-codec constraints constrain what EACH key looks like.

EntrySlice[K,V,R] decodes to Codec[[]R] (a slice), not Codec[map[K]V] — its entry-count constraint is therefore validate.MinItems[R]/MaxItems[R]/NonEmptySlice[R] (the SLICE constructors), not the map ones above, even though EntrySlice's generated schema is object-shaped (type: object, matching its wire format). This is a known, harmless schema/type mismatch: a JSON Schema validator ignores minItems/maxItems on a type: object schema (those keywords only apply where type: array), so the RUNTIME constraint still enforces correctly via len(), but the constraint won't appear in the rendered OpenAPI/AsyncAPI spec for that field. If the spec annotation matters for an EntrySlice field, use a custom codex.Constraint[[]R] with a Schema callback that sets MinProperties/MaxProperties directly instead.

Merging key and value into one type (EntrySlice)

EntrySlice[K, V, R] handles the case where the object key itself carries domain meaning that belongs inside the value struct. It decodes a JSON/YAML/TOML object into []R, merging the decoded key and value into each element.

Use case: Azure IoT Edge device twins, Kubernetes ConfigMaps, and similar formats use dotted keys like "properties.desired.modules.cv-writer-kvrocks" where the last segment is the container name. With EntrySlice, this decodes directly into []Container — no post-processing loop needed.

Put the prefix logic in the key codec, not in merge/split. This way: - The key codec validates the full wire key and the extracted container name separately - merge receives only the already-extracted name — no strings.TrimPrefix needed - split returns only the name — the codec re-adds the prefix on encode

const prefix = "properties.desired.modules."

var containerNameConstraint = codex.Constraint[string]{
    Name:  "container-name",
    Check: func(v string) bool { return len(v) > 0 && !strings.ContainsAny(v, " /_") },
}
var moduleKeyConstraint = codex.Constraint[string]{
    Name:  "module-key-path",
    Check: func(v string) bool { return strings.HasPrefix(v, prefix) && len(v) > len(prefix) },
}

// containerKeyCodec: wire validates full dotted key; domain validates container name.
var containerKeyCodec = codex.MapCodecValidated(
    codex.String().Refine(moduleKeyConstraint),      // wire: "properties.desired.modules.cv-writer"
    codex.String().Refine(containerNameConstraint),   // domain: "cv-writer"
    func(fullKey string) (string, error) {            // decode: strip prefix
        return strings.TrimPrefix(fullKey, prefix), nil
    },
    func(name string) (string, error) {               // encode: add prefix
        return prefix + name, nil
    },
)

var containersCodec = codex.EntrySlice(
    containerKeyCodec,   // K = string (container name)
    moduleCodec,         // V = ModuleConfig
    func(name string, m ModuleConfig) Container {     // merge: name already extracted
        return Container{Name: name, Image: m.Image, Status: m.Status}
    },
    func(c Container) (string, ModuleConfig) {        // split: returns container name
        return c.Name, ModuleConfig{Image: c.Image, Status: c.Status}
    },
)
// → Codec[[]Container]

Decode path — multiple containers

Given the wire object:

{
  "properties.desired.modules.cv-writer-kvrocks": {"image": "myregistry/cv-writer:1.0", "status": "running"},
  "properties.desired.modules.cv-writer-gateway": {"image": "myregistry/gateway:3.1",  "status": "running"},
  "properties.desired.modules.analytics-engine":  {"image": "myregistry/analytics:2.1", "status": "stopped"}
}

containersCodec.Decode(rawJSON) iterates every key-value pair:

Wire key After containerKeyCodec.Decode After moduleCodec.Decode After merge
"...cv-writer-kvrocks" "cv-writer-kvrocks" ModuleConfig{Image:"...", Status:"running"} Container{Name:"cv-writer-kvrocks", Image:"...", Status:"running"}
"...cv-writer-gateway" "cv-writer-gateway" ModuleConfig{Image:"...", Status:"running"} Container{Name:"cv-writer-gateway", ...}
"...analytics-engine" "analytics-engine" ModuleConfig{Image:"...", Status:"stopped"} Container{Name:"analytics-engine", ...}

Result: []Container with 3 elements. Sort after decode if order matters — JSON object key order is not guaranteed.

Encode path — multiple containers

containersCodec.Encode(containers) processes each element:

  1. split(c)(c.Name, ModuleConfig{c.Image, c.Status})
  2. containerKeyCodec.Encode(c.Name) → validates name → "properties.desired.modules." + c.Name
  3. moduleCodec.Encode(ModuleConfig{...}){"image":"...","status":"..."}
  4. Writes one key-value pair into the output map

Result: the same flat dotted-key object as the input. All three containers are present. Key validation happens for every element — a single invalid name (e.g. one with an underscore) stops encoding and returns KeyError{Key: "bad_name", Err: ConstraintError{Name:"container-name",...}}.

Format compatibility

Format Flat dotted key Notes
JSON Always flat string keys
YAML (quoted) "properties.desired.modules.cv-writer": → flat key
TOML (quoted headers) ["properties.desired.modules.cv-writer"] → flat key
TOML (bare dotted) ⚠️ [properties.desired.modules]nested per TOML spec — use quoted headers

Extracting multiple fields from a key

K doesn't have to be string. Any comparable Go type works — including a struct. Use a Struct[ModuleKey] domain codec with MapCodecValidated to parse and validate each segment independently.

Key format: "properties.desired.modules.<tenant>.<container-name>"
Two segments → ModuleKey{Tenant, Name} — extracted in one key codec, no parsing in merge/split.

type ModuleKey struct {
    Tenant string // "tenant-acme"
    Name   string // "cv-writer-kvrocks"
}

type TenantContainer struct {
    Tenant string
    Name   string
    Image  string
    Status string
}

// Domain codec validates each field independently.
var moduleKeyStructCodec = codex.Struct[ModuleKey](
    codex.RequiredField("tenant", codex.String().Refine(tenantConstraint),
        func(k ModuleKey) string { return k.Tenant },
        func(k *ModuleKey, v string) { k.Tenant = v },
    ),
    codex.RequiredField("name", codex.String().Refine(containerNameConstraint),
        func(k ModuleKey) string { return k.Name },
        func(k *ModuleKey, v string) { k.Name = v },
    ),
)

// Two-layer key codec:
//   wire  — validates the full dotted key
//   domain — validates each extracted segment via moduleKeyStructCodec
var twoPartKeyCodec = codex.MapCodecValidated(
    codex.String().Refine(twoPartKeyConstraint),  // "properties.desired.modules.t.n" ✓
    moduleKeyStructCodec,                          // {Tenant:"t", Name:"n"} ✓
    func(fullKey string) (ModuleKey, error) {      // decode: strip prefix + split
        rest := strings.TrimPrefix(fullKey, prefix)
        parts := strings.SplitN(rest, ".", 2)
        return ModuleKey{Tenant: parts[0], Name: parts[1]}, nil
    },
    func(k ModuleKey) (string, error) {            // encode: reassemble
        return prefix + k.Tenant + "." + k.Name, nil
    },
)

// K = ModuleKey (comparable struct — all fields are strings)
var tenantContainersCodec = codex.EntrySlice(
    twoPartKeyCodec,
    moduleCodec,
    func(k ModuleKey, m ModuleConfig) TenantContainer {
        return TenantContainer{Tenant: k.Tenant, Name: k.Name, Image: m.Image, Status: m.Status}
    },
    func(c TenantContainer) (ModuleKey, ModuleConfig) {
        return ModuleKey{Tenant: c.Tenant, Name: c.Name},
               ModuleConfig{Image: c.Image, Status: c.Status}
    },
)
// → Codec[[]TenantContainer]

Decode table — wire to Go:

Wire key twoPartKeyCodec.Decode After merge
"...tenant-acme.cv-writer-kvrocks" ModuleKey{Tenant:"tenant-acme", Name:"cv-writer-kvrocks"} TenantContainer{Tenant:"tenant-acme", Name:"cv-writer-kvrocks", ...}
"...tenant-acme.cv-writer-gateway" ModuleKey{Tenant:"tenant-acme", Name:"cv-writer-gateway"} TenantContainer{...}
"...tenant-beta.analytics-engine" ModuleKey{Tenant:"tenant-beta", Name:"analytics-engine"} TenantContainer{...}

Field-level validation on encode: TenantContainer{Tenant: "tenant_acme", ...} (underscore in tenant) → moduleKeyStructCodec.ValidateValidationErrors[{Field:"tenant", Err:ConstraintError{Name:"tenant-name"}}] → wrapped in KeyError{Key:"{tenant_acme ...}", Err:...}. The error identifies the failing field, not just "the key is invalid".

General rule: for N segments in a key, use K = struct with N fields. Go structs are comparable when all fields are comparable types (strings, ints, etc.).

See examples/flat-key-patch Section 10 for the full runnable demo.

Static key — constant name injected via MapCodecSafe

When the key is known at compile time (it never varies), the container name is not on the wire — it only exists as the field name literal in the codec. There is nothing to decode. Inject it as a constant.

Wire format (the key is always exactly this):

{
  "properties.desired.modules.cv-writer-kvrocks": {"image": "myregistry/cv-writer:1.0", "status": "running"}
}

Goal: Container{Name: "cv-writer-kvrocks", Image: "...", Status: "running"} — name comes from the key, which we know statically.

Pattern: wrap the value codec with MapCodecSafe to inject the constant name during decode and strip it on encode (name is not serialized into the value object):

const cvWriterKeyName = "cv-writer-kvrocks"
const cvWriterKey     = "properties.desired.modules." + cvWriterKeyName

// valueWithNameCodec: decodes ModuleConfig → Container{Name: constant, ...}
//                     encodes Container     → ModuleConfig (drops Name — not on wire)
var valueWithNameCodec = codex.MapCodecSafe(
    moduleCodec,
    func(m ModuleConfig) Container {          // decode: inject constant
        return Container{Name: cvWriterKeyName, Image: m.Image, Status: m.Status}
    },
    func(c Container) (ModuleConfig, error) { // encode: drop name
        return ModuleConfig{Image: c.Image, Status: c.Status}, nil
    },
)

// Outer codec: the full dotted key is the field name. Dots are valid field names.
var cvWriterCodec = codex.Struct[Container](
    codex.RequiredField(cvWriterKey, valueWithNameCodec,
        func(c Container) Container { return c },
        func(outer *Container, inner Container) { *outer = inner },
    ),
)
// Decode: {"properties.desired.modules.cv-writer-kvrocks": {"image":"...","status":"running"}}
// Result: Container{Name:"cv-writer-kvrocks", Image:"...", Status:"running"}
//
// Encode: Container{Name:"cv-writer-kvrocks", Image:"...", Status:"running"}
// Result: {"properties.desired.modules.cv-writer-kvrocks": {"image":"...","status":"running"}}
//          ↑ Name is NOT in the value object — only the key encodes it

When to use which pattern:

Scenario Key known at Name on wire? Use
Always the same container Compile time No codex.Struct + MapCodecSafe to inject constant
Any container name (1 entry) Runtime In key EntrySlice + MapCodecValidated to unwrap []R
Multiple containers Runtime In keys EntrySlice directly → Codec[[]Container]
Multi-tenant containers Runtime In key (2 segments) EntrySlice + MapCodecValidated key codec

See examples/flat-key-patch Section 11 for the full runnable demo.

Schema generation and OpenAPI / AsyncAPI embedding

EntrySlice emits the same schema as Map[K, V] — an object schema. The Go type is []R (a slice), but the wire format is a JSON object, so the schema correctly documents what is on the wire:

type: object
propertyNames:           # key codec schema — validates each JSON key
  type: string
  title: ContainerName   # set via .WithTitle() on the key codec
  minLength: 1
additionalProperties:    # value codec schema — validates each JSON value
  type: object
  properties:
    image:
      type: string
      minLength: 1
    status:
      type: string
      enum: [running, stopped]
  required: [image, status]

It embeds transparently into OpenAPI and AsyncAPI — use it anywhere you'd use any other Codec[T]:

// OpenAPI — as a response body
rest.NewRoute[GetReq, []Container]("GET", "/twins/{id}/modules",
    reqCodec,
    containersCodec,    // Codec[[]Container] — emits object schema in spec
    rest.RouteMeta{OperationID: "listModules"},
)

// AsyncAPI — as a channel payload
events.NewChannel[[]Container]("twins/{id}/modules", containersCodec,
    events.Subscribe{Summary: "Module container list from device twin."},
)

Both produce the same type: object schema in their respective specs.

propertyNames.title documents the key role. Add .WithTitle(...) on the domain string codec in your key codec to give the key a readable name in the schema:

codex.String().Refine(validate.NonEmptyString).
    WithTitle("ContainerName").
    WithDescription("Container name — appears as the JSON object key, not a value field.")

What does NOT appear in the schema: the Name field of Container — because name is not in moduleCodec. The schema documents the wire format (key = container name, value = {image, status}), not the Go struct. This is correct: on the wire, name IS the key.

Cross-field constraints: RefineFunc

RefineFunc wraps a func(T) error applied on both Encode and Decode. Use it to validate relationships between fields:

var dateRangeCodec = codex.Struct[DateRange](
    codex.RequiredField("start", codex.Time(), ...),
    codex.RequiredField("end",   codex.Time(), ...),
).RefineFunc(func(r DateRange) error {
    if !r.End.After(r.Start) {
        return errors.New("end must be after start")
    }
    return nil
})

Unknown fields on decode

The decode loop iterates only over the fields you declared. Keys present in the incoming object but not in the codec are silently dropped — they never touch the result struct and do not cause an error.

// Wire JSON: {"name":"Alice","email":"alice@example.com","role":"admin","_internal":123}
// Codec declares: "name", "email" only
// Result: User{Name: "Alice", Email: "alice@example.com"}  ← "role", "_internal" dropped

The encode side is symmetric: Encode writes only declared fields. Any Go struct fields without a codec entry are absent from the output.

This is intentional — it makes the codec resilient to API evolution. A producer can add new fields without breaking consumers that have not declared them yet.

If you need to reject unknown fields, add a RefineFunc that compares incoming keys against the declared set. The generated schema does not emit "additionalProperties": false by default.

Encode, Decode, and Validation

Direction What runs Rationale
Decode type checks + all Refine constraints Input is untrusted — every constraint runs
Encode type conversion + all Refine constraints Constraints also run on outgoing path — invalid values cannot be serialised
// Decode — validates automatically
user, err := jsonFmt.Unmarshal([]byte(`{"name":"","age":-5}`))
// err: field name: constraint failed (non-empty): expected non-empty string

// Encode — also validates
data, err := jsonFmt.Marshal(User{Name: "", Age: -5})
// err: field name: constraint failed (non-empty): ...

// Validate — explicit round-trip check
if err := UserCodec.Validate(u); err != nil {
    return fmt.Errorf("constructed invalid user: %w", err)
}

Smart constructors: New and Must

// New: validate + return in one call.
email, err := emailCodec.New(Email("user@example.com"))
if err != nil { return err }
// email is guaranteed valid

// Must: panic-on-error for package-level constants and test data.
var guestUser = codex.Must(usernameCodec.New(Username("guest")))

Named per-field constructors for struct codecs

Codec[T].New is the one smart-constructor primitive — go-codex does not (and will not) auto-derive a NewUser(field1, field2, ...) constructor from a codex.Struct[T] codec. Two hard constraints rule that out: go-codex has no reflection and no struct-tag codec wiring (all field wiring is explicit Go code via Field/RequiredField/OptionalField/DefaultField), and Go generics cannot express a variadic-arity type parameter list, so one generic helper can't cover struct codecs of every arity.

The idiomatic pattern is a thin, hand-written wrapper that takes positional field values and delegates to Codec.New — the full codec validation (every field's Refine constraints) still runs on every call:

var UserCodec = codex.Struct[User](
    codex.RequiredField("name", nameCodec,
        func(u User) string { return u.Name },
        func(u *User, v string) { u.Name = v }),
    codex.RequiredField("age", ageCodec,
        func(u User) int { return u.Age },
        func(u *User, v int) { u.Age = v }),
)

func NewUser(name string, age int) (User, error) {
    return UserCodec.New(User{Name: name, Age: age})
}

u, err := NewUser("alice", 30)
if err != nil { return err }
// u.Name and u.Age both passed their Refine constraints

See examples/construction/main.go (Profile/NewProfile) for a runnable version.

HasCodec[T]: letting a type carry its own codec

Codec[T].New/Validate above always need the codec value in scope at the call site (emailCodec.New(...)). codex.HasCodec[T] is an opt-in interface — the closest Go gets to Pydantic's "the model knows how to validate/serialize itself" without inheritance (Go has none) — that lets a type declare its OWN canonical codec once, so generic helper functions can then work on it without repeating the codec's name:

// HasCodec is implemented by types that declare their canonical Codec.
type HasCodec[T any] interface {
    Codec() Codec[T]
}

func Validate[T HasCodec[T]](v T) error
func New[T HasCodec[T]](v T) (T, error)
func EncodeSelf[T HasCodec[T]](v T) (any, error)
func DecodeAs[T HasCodec[T]](raw any) (T, error)
func SchemaOf[T HasCodec[T]]() schema.Schema

A type implements it with one method:

type Image struct{ Name, Tag string }

var imageCodec = codex.Struct[Image](
    codex.RequiredField("name", codex.String().Refine(validate.NonEmptyString),
        func(i Image) string { return i.Name },
        func(i *Image, v string) { i.Name = v }),
    codex.OptionalField("tag", codex.String(),
        func(i Image) string { return i.Tag },
        func(i *Image, v string) { i.Tag = v }),
)

// Value receiver, package-level codec — the common case (no per-instance state).
func (Image) Codec() codex.Codec[Image] { return imageCodec }

...and every generic helper works on it, with zero further per-type code:

img, err := codex.New(Image{Name: "alpine", Tag: "latest"})
err = codex.Validate(img)
raw, err := codex.EncodeSelf(img)
back, err := codex.DecodeAs[Image](raw)
s := codex.SchemaOf[Image]()

Zero-value-call contract for DecodeAs/SchemaOf. Neither has a T value to call .Codec() on yet, so both call it on T's zero value (var zero T; zero.Codec()...). This is correct and side-effect-free for the documented common case (a stateless Codec()), but a HasCodec implementation whose Codec() genuinely depends on instance state must NOT be used with DecodeAs/SchemaOf — its zero value would return the WRONG codec. Validate/New/EncodeSelf have no such restriction; they always call Codec() on the actual value passed in.

This does not change the "no auto-derived NewUser(...)" decision above. HasCodec gives you generic DISPATCH over a codec you already declared by hand — it is not a way to synthesize a constructor from field names, and the same reflection/struct-tag/variadic-arity limitations still apply. Adoption is entirely opt-in, exactly like Codec[T].New itself — no existing type or package is required to implement HasCodec.

PartialField/PartialStruct: patching an existing struct

Struct[T]'s own Encode unconditionally writes EVERY declared field into its output map — RequiredField and OptionalField alike. There is no built-in "omit this field from the encoded object when it was never set" behavior, so a "patch"/"partial update" struct (only SOME fields set, and only those written to the wire) can't be expressed with Struct directly. PartialField/PartialStruct are a parallel interface/constructor pair — not a modification of FieldCodec/Struct — for exactly that case:

type ProfilePatch struct {
    Nickname *string
    Age      *int
}
var profilePatchCodec = codex.PartialStruct[ProfilePatch](
    codex.PartialField("nickname", codex.String(),
        func(p ProfilePatch) *string { return p.Nickname },
        func(p *ProfilePatch, v *string) { p.Nickname = v }),
    codex.PartialField("age", codex.Int(),
        func(p ProfilePatch) *int { return p.Age },
        func(p *ProfilePatch, v *int) { p.Age = v }),
)

raw, _ := profilePatchCodec.Encode(ProfilePatch{Nickname: ptr("bob")})
// raw == map[string]any{"nickname": "bob"} — "age" key is ABSENT, not null.

Every PartialField-declared field is required to be a pointer *F on T: nil means "not set, leave untouched" when encoding; non-nil means "set to this value". codec is the SAME field-level Codec[F] an existing full-struct declaration for this concept already uses — reused completely unchanged, so a patch field's validation is inherited from the base type's own codec with zero new logic. Decode is the mirror: only fields whose keys are present in the input get assigned; absent keys leave the target pointer nil. If every field is nil, Encode returns an empty map[string]any (not an error — a caller for whom an entirely-empty patch is meaningless should reject it themselves via their own domain error).

Why not just extend OptionalField? FieldCodec[T]'s sealed encode method returns (string, any, error) — no room for a "was this actually set" signal without either a breaking interface change touching every existing Field[T,F] consumer, or an ad-hoc sentinel value threaded through any (exactly the kind of implicit-behavior hack this library avoids elsewhere). A parallel interface avoids touching FieldCodec/Struct at all, and keeps Struct's well-tested "always-write-every-field" semantics completely separate from PartialStruct's "omit-when-unset" semantics — two clearly-named, single-purpose entry points instead of one function with two presence models a reader would have to hold in their head at once.

Nesting needs no special mechanism. A PartialStruct-built Codec[F] can be used as another PartialField[Outer, F]'s codecPartialField accepts any Codec[F], and PartialStruct returns a plain Codec[T], so this is ordinary Codec[T] composability (the same way Struct already nests inside Struct today). Presence for the nested field is decided exactly like any other field: is the OUTER pointer nil? The caller only allocates the nested pointer (&SettingsPatch{Image: &img}) when it actually means to include a change inside it — there is no "auto-collapse an empty nested result" heuristic involved.

Nesting gotcha: presence is decided by != nil, not "has any inner field set" — so a non-nil-but-entirely-empty nested pointer (e.g. &SettingsPatch{} with every one of ITS OWN fields left nil) still encodes as present ("settings": {} — a key with an empty object, not an absent key). This is a natural consequence of the nil-means-unset rule, not a bug, but it's a real footgun: only allocate a nested patch pointer when you're about to set at least one field inside it.

Gotcha: named types over Map/SliceOf. codex.Map[K,V]/ codex.SliceOf[T] return a Codec over the PLAIN underlying type (map[K]V/[]T), not any named type built on top of it. If F in PartialField[T,F] is a named type (e.g. type EnvVars map[EnvVarName]EnvVar), passing that raw Map-built codec directly to PartialField fails Go's generic type inference — even though the underlying representation is identical. Fix: wrap it in a trivial identity retype via codex.MapCodecSafe (Codec[map[K]V]Codec[NamedType]) before passing it to PartialField — see modulepatch.envVarsCodec for the reference pattern.

No new structured error types and no observer integration are needed: PartialStruct/PartialField reuse whatever errors the per-field Codec[F] values already produce (ValidationError/ValidationErrors, exactly like Struct), and are pure codec-construction primitives with no I/O, same as Struct/UntaggedUnion/TaggedUnion. See examples/go-edge-models/models/iotedge/modulepatch.ModuleFieldsPatch for a real-world worked example (patching one IoT-Edge module's fields, including a nested ModuleSettingsPatch grouping) — this subsection and .github/instructions/go-codex.instructions.md's matching section are now the authoritative design reference (the feature's roadmap doc was retired once shipped).

Either — typed sum type

Either2 tries codec A first; if decode fails, tries codec B. Encode uses whichever branch is non-nil:

var dsnOrConfig = codex.Either2(codex.String(), dbConfigCodec)
// Codec[codex.Either[string, DBConfig]]

left, _ := dsnOrConfig.Decode("postgres://localhost/db")
// left.Left = &"postgres://...", left.Right = nil

If both branches fail, returns EitherError{Errors: []error{errA, errB}}.

StringOrInt64 and family — the "string or number" convenience

A config/env-style value that may be EITHER a string OR a number is a genuinely common wire pattern — Docker/IoT-Edge module env vars ("5" vs 5), Kubernetes' apimachinery IntOrString, Terraform/HCL, Helm values.yaml. codex.StringOrInt64() (and its siblings StringOrInt/StringOrInt32/StringOrUint/StringOrUint64/StringOrFloat32/StringOrFloat64, one per numeric primitive) is a one-line named convenience over exactly the Either2 pattern above:

func StringOrInt64() Codec[Either[string, int64]] { return Either2(String(), Int64()) }

var EnvVarCodec = codex.Struct[EnvVar](
    codex.RequiredField("value", codex.StringOrInt64(),
        func(e EnvVar) codex.Either[string, int64] { return e.Value },
        func(e *EnvVar, v codex.Either[string, int64]) { e.Value = v },
    ),
)

Format-agnostic by construction: encoding/json always decodes a bare JSON number into any as float64; yaml.v3 decodes a YAML integer as a native Go int (and a float as float64); BurntSushi/toml decodes a TOML integer as int64 (and a float as float64). Every numeric primitive (Int/Int32/Int64/Uint/Uint64/Float32/Float64) already type-switches over ALL of these native representations in its own Decode — so the SAME StringOrInt64() codec works correctly whether the surrounding document is JSON, YAML, or TOML, with zero format-specific handling.

A plain custom Constraint[T] cannot express this: it validates one FIXED type T after a successful Decode into T — there is no T for which Check could ever see the "other" type's raw value. The type-level choice has to happen in the codec itself, which is exactly what Either2/UntaggedUnion provide.

UntaggedUnion — interface union without discriminator

var shapeCodec = codex.UntaggedUnion[Shape](
    func(s Shape) int {
        switch s.(type) {
        case Circle: return 0
        case Rect:   return 1
        }
        return -1
    },
    codex.UntaggedVariant[Shape]{Name: "circle", Codec: circleCodec},
    codex.UntaggedVariant[Shape]{Name: "rect",   Codec: rectCodec},
)

Decode: first-match wins. Schema: {oneOf: [{...circle...}, {...rect...}]}.

Pure and Eq — fixed and single-value codecs

Pure always decodes to a fixed value (ignoring wire input). Eq rejects anything that doesn't equal a specific value:

var CloudEventCodec = codex.Struct[CloudEvent](
    // Pure: always decodes to "1.0" regardless of wire value
    codex.RequiredField("specversion", codex.Pure("1.0"), ...),
    // Eq: only accepts exactly "com.example.order.placed"
    codex.RequiredField("type", codex.Eq(codex.String(), "com.example.order.placed"), ...),
)

MapCodecSafe and MapCodecValidated

Both build Codec[B] from Codec[A] via mapping functions:

// MapCodecSafe — total decode direction, fallible encode direction
type Email string
var EmailCodec = codex.MapCodecSafe(
    codex.String(),
    func(s string) Email { return Email(s) },
    func(e Email) (string, error) { return string(e), nil },
)

// MapCodecValidated — both directions may fail; post-decode validation via cb
var celsiusCodec = codex.MapCodecValidated(
    codex.Float64(),    // ca: wire codec
    celsiusBaseCodec,   // cb: domain codec with range constraints
    func(f float64) (Celsius, error) {
        if f != f { return 0, errors.New("NaN is not a valid temperature") }
        return Celsius(f), nil
    },
    func(c Celsius) (float64, error) { return float64(c), nil },
)

Rule: use MapCodecSafe for newtypes and type-safe wrappers; use MapCodecValidated when both directions may fail and the target type carries its own constraints.

Encoding / decoding escaped or re-serialised fields

A recurring pattern in APIs and event systems is a field whose wire value is itself a serialised string — JSON, YAML, or TOML encoded as a string, or URL-encoded, or HTML-escaped content. The format package provides built-in functions for all three serialisation formats:

JSON-in-string, YAML-in-string, TOML-in-string

Common in CloudEvents data as string, database JSONB stored via REST API, Kafka message headers, and device-twin configuration fields:

{"event": "user.created", "payload": "{\"id\":\"123\",\"name\":\"Alice\"}"}

Use format.EmbeddedJSON, format.EmbeddedYAML, or format.EmbeddedTOML:

import "github.com/DaniDeer/go-codex/format"

// Wire: {"event":"user.created","payload":"{\"id\":\"123\",\"name\":\"Alice\"}"}
// Go:   Event{Type:"user.created", Payload:User{ID:"123", Name:"Alice"}}
var eventCodec = codex.Struct[Event](
    codex.RequiredField("event",   codex.String(), ...),
    codex.RequiredField("payload", format.EmbeddedJSON(userCodec), ...),
)

// YAML or TOML — same pattern, different format
var configEventCodec = codex.Struct[ConfigEvent](
    codex.RequiredField("config", format.EmbeddedYAML(configCodec), ...),
)
var twinFieldCodec = codex.Struct[TwinField](
    codex.RequiredField("settings", format.EmbeddedTOML(settingsCodec), ...),
)

Decode path: format.EmbeddedJSON (1) parses the string with json.Unmarshalmap[string]any, then (2) calls inner.Decode which applies the codec constraints. Passing the raw string directly to inner.Decode would fail — the codec expects an intermediate, not a byte slice.

Encode path: (1) inner.Encodemap[string]any, (2) json.Marshal → JSON string.

Both paths covered: encode and decode are symmetric; inner codec constraints run on both.

Error types (both implement slog.LogValuer): - format.EmbeddedDecodeError{Format, Err} — when the string cannot be parsed as the expected format - format.EmbeddedEncodeError{Format, Err} — when the Go value cannot be marshalled to the format string - Codec validation errors from the inner codec propagate unchanged (they are not wrapped)

Format numeric type compatibility — YAML integers unmarshal as int, TOML integers as int64. The built-in codex.Int(), codex.Int64() primitives handle all three (int, int64, float64) so existing codecs work without changes.

URL-encoded and HTML-escaped fields

The same pattern applies to other string encodings — only the mapping functions change:

import (
    "html"
    "net/url"
)

// URL-encoded field (e.g. query parameters stored in a JSON field)
var urlDecodedCodec = codex.MapCodecSafe(
    codex.String(),
    func(s string) string {
        decoded, _ := url.QueryUnescape(s)
        return decoded
    },
    func(s string) (string, error) {
        return url.QueryEscape(s), nil
    },
)

// HTML-escaped field (e.g. legacy API returning HTML entities in JSON strings)
var htmlUnescapedCodec = codex.MapCodecSafe(
    codex.String(),
    func(s string) string { return html.UnescapeString(s) },
    func(s string) (string, error) { return html.EscapeString(s), nil },
)

MapCodecSafe is correct here because unescaping is infallible (any string is valid input) and escaping always succeeds.

Summary

Wire value Pattern Function
"{\"id\":\"123\"}" JSON-in-string format.EmbeddedJSON(innerCodec)
"{name: alice, value: 42}" YAML-in-string format.EmbeddedYAML(innerCodec)
"name = \"alice\"\nvalue = 42" TOML-in-string format.EmbeddedTOML(innerCodec)
"user%3A123" URL-encoded MapCodecSafe with url.QueryUnescape/url.QueryEscape
"&lt;b&gt;Hello&lt;/b&gt;" HTML-escaped MapCodecSafe with html.UnescapeString/html.EscapeString

format.EmbeddedJSON, format.EmbeddedYAML, and format.EmbeddedTOML are library functions in the format package. On format parse failure they return format.EmbeddedDecodeError{Format, Err}; on marshal failure format.EmbeddedEncodeError{Format, Err} — both implement slog.LogValuer. Codec validation errors from the inner codec propagate unchanged.

Composing with ports.File[T]

Because EmbeddedJSON is just a Codec[T], it composes transparently with ports.File[T] which means EmbeddedJSON codec participates in the decode pass exactly like any other field codec. The outer file format (JSON/YAML/TOML) handles the file bytes; the inner EmbeddedJSON field codec handles the string-to-struct conversion — no special wiring needed.

// File: events/user-created.json
// {
//   "event": "user.created",
//   "payload": "{\"id\":\"123\",\"name\":\"Alice\"}"
// }

var eventCodec = codex.Struct[Event](
    codex.RequiredField("event",   codex.String(), ...),
    codex.RequiredField("payload", format.EmbeddedJSON(userCodec), ...),
)
var eventFile = ports.NewFile("events/user-created.json", format.JSON(eventCodec))

event, err := eventFile.Read(nil, ports.FileOptions{})
// event.Payload == User{ID:"123", Name:"Alice"}

// Write — EmbeddedJSON encodes User → JSON string on the way out
err = eventFile.Write(nil, event, ports.FileOptions{Perm: 0644})
// Writes: {"event":"user.created","payload":"{\"id\":\"123\",\"name\":\"Alice\"}"}

The decode chain: os.ReadFilejson.Unmarshalmap[string]anyeventCodec.Decode → for the payload field: EmbeddedJSON parses the string value → userCodec.DecodeUser. Each layer only sees its own responsibility.

This works identically with format.YAML(eventCodec) and format.TOML(eventCodec) as the outer format, and with template paths:

// Template path with codec-validated path variable
var userEventFile = ports.NewFile(
    "events/{userID}/latest.json",
    format.JSON(eventCodec),
    ports.FilePathParam{Name: "userID"}.WithCodec(codex.String().Refine(validate.UUID)),
)
event, err = userEventFile.Read(
    map[string]string{"userID": "f47ac10b-58cc-4372-a567-0e02b2c3d479"},
    ports.FileOptions{},
)

Schema metadata

var emailCodec = codex.String().
    Refine(validate.Email).
    WithDescription("Primary contact email.").
    WithExample("alice@example.com").   // → example: alice@example.com in OpenAPI
    WithTitle("Email")                   // → title in schema

var legacyIPCodec = codex.String().
    Refine(validate.IPv4).
    WithDeprecated()                     // → deprecated: true in OpenAPI

Custom format extensibility

The format package provides two constructors for custom wire formats:

Constructor Intermediate Use cases
format.New[T](codec, marshal, unmarshal) map[string]any CBOR, MessagePack, XML
format.NewTyped[T](codec, marshal, unmarshal, ct) typed T directly templ HTML, Protobuf, CSV
format.NewStreamed[T](codec, marshalTo, unmarshal, ct) writes to io.Writer SSR streaming, chunked responses
// Custom MessagePack format
msgpackFmt := format.New(userCodec,
    func(v any) ([]byte, error) { return msgpack.Marshal(v) },
    func(b []byte) (any, error) { var m any; return m, msgpack.Unmarshal(b, &m) },
).WithContentType("application/msgpack")

Builtin constraints (validate/)

Format constraints (annotate schema automatically):

Constraint Validates OpenAPI format
validate.Email user@domain.tld email
validate.UUID RFC 4122 UUID uuid
validate.URL absolute http/https URL uri
validate.URLWithSchemes(s...) URL restricted to given schemes uri
validate.URI absolute URI with any scheme uri
validate.Hostname RFC 1123 hostname hostname
validate.IPv4 dotted-decimal IPv4 ipv4
validate.IPv6 IPv6 address ipv6
validate.IP IPv4 or IPv6 ip
validate.Date YYYY-MM-DD date
validate.Time RFC 3339 time-only time
validate.DateTime RFC 3339 date-time date-time
validate.SemVer semantic version pattern
validate.SemVerLike version-shaped but not strict semver (e.g. 3.1-debian, 18.04) — overlaps SemVer's grammar for the common case, but not build metadata; check SemVer first when classifying pattern
validate.Slug lowercase-hyphen-slug pattern
validate.CIDR CIDR notation (none)
validate.ContainerImage OCI container image reference (e.g. alpine:latest, docker.io/library/nginx:1.25) (none)
validate.BearerToken non-empty, no leading/trailing whitespace
validate.JWT compact JWT (header.payload.signature)
validate.EnvVarName POSIX env var name (^[A-Z_][A-Z0-9_]*$) pattern
validate.EnvVarPrefix(prefix) starts with given prefix
validate.IntString / PositiveIntString / NonNegativeIntString / IntStringInRange integer as string

Range/length constraints:

Constraint Applies to Validates
validate.NonEmptyString string not empty
validate.MinLen(n) / MaxLen(n) string character count
validate.OneOf(values...) string enum membership
validate.Pattern(re) string regexp match
validate.PositiveInt / NegativeInt / NonZeroInt int sign
validate.MinInt(n) / MaxInt(n) / RangeInt(a,b) int bounds
validate.PositiveFloat / NonZeroFloat float64 sign
validate.MinFloat(n) / MaxFloat(n) / RangeFloat(a,b) float64 bounds
validate.PositiveDuration / MinDuration(d) time.Duration duration bounds
validate.MaxBytes(n) / MinBytes(n) []byte byte count

Custom constraints

// Inline (one-off):
var AvatarCodec = codex.Bytes().Refine(codex.Constraint[[]byte]{
    Name:    "maxBytes(65536)",
    Check:   func(v []byte) bool { return len(v) <= 65536 },
    Message: func(v []byte) string {
        return fmt.Sprintf("expected at most 65536 bytes, got %d", len(v))
    },
})

// Reusable with schema annotation (propagates to OpenAPI):
func MaxLen(n int) codex.Constraint[string] {
    return codex.Constraint[string]{
        Name:  fmt.Sprintf("maxLen(%d)", n),
        Check: func(v string) bool { return len(v) <= n },
        Message: func(v string) string {
            return fmt.Sprintf("expected at most %d characters, got %d", n, len(v))
        },
        Schema: func(s schema.Schema) schema.Schema {
            s.MaxLength = &n    // reflected into OpenAPI output automatically
            return s
        },
    }
}

See also