Codec — declare once¶
See also:
codexpackage 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: Struct → SliceOf(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 |
OmitEmptyField/OmitEmptyFieldFunc |
Go zero value ("", 0, nil, …) |
OMITTED when current value is "empty" (== zero or the isEmpty predicate), else encoded |
field not in required |
OmitDefaultField |
declared default applied | OMITTED when current value equals the declared default, else encoded | field not in required; default: <value> |
Encode note:
RequiredField/OptionalField/DefaultFieldnever omit a field on encode — every one of them always writes the key to the output object, even at the Go zero value. For an OPT-IN way to omit a zero-valued (or default-valued) field's key entirely, see "OmitEmptyField/OmitEmptyFieldFunc/OmitDefaultField" below.
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 SchemaadditionalProperties: false) —Structnever 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
Structstays non-strict even when the OUTER struct usesStrictStruct. Opt in explicitly at each nesting level, exactly likeRequired/Optional/Defaultare declared independently per level (see Nested structs). Encodeis unchanged fromStruct— "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.StringCodec(parse, format, schema)/
codex.TextCodec[V]()/codex.IntString() (etc.) for typed fields like
int/uuid.UUID (vars are always string-valued at the wire level: path
segments, topic segments, header/query/cookie values) — see "Typed
(non-string) path/topic/query/header/cookie/key vars" below.
Per-boundary sugar: rest.NewPathParam[T, V]/NewRequiredQueryParam[T, V]/
NewOptionalQueryParam[T, V] (+ Header/Cookie equivalents),
events.NewTopicParam[T, V], reqreply.NewTopicParam[T, V], and
ports.NewFilePathParam[T, V] 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/TopicParam
struct literals remain available as the low-level, validate-only
alternative (codex.Param's own escape-hatch role — see below).
Every one of these boundary-specific PathParam/TopicParam/
FilePathParam types is a thin wrapper over ONE shared pair of primitives
in codex: codex.Param{Name, Description, Codec} (the validate-only
escape hatch) and codex.MergedParam[T]/codex.NewParam[T, V] (the
merge-capable counterpart, built via the SAME RequiredField mechanism
above). codex.ValidateParams/codex.ValidateDeclaredParams/
codex.BuildFromParams are the shared validate-loop/declaration-check/
build-substitution bodies every boundary's BuildPath/BuildTopic/
ValidatePathParams/ValidateTopicVars methods delegate to internally —
one canonical implementation instead of five independently-duplicated
copies (api/rest, api/events, api/reqreply, ports/file.go,
ports/dir.go). Each boundary keeps its OWN struct name/vocabulary
(PathParam reads as "a REST path variable", TopicParam as "an
event/reqreply topic variable") since Go requires a package-owned type to
satisfy that package's own sealed option interface (RouteOpt/
ChannelOpt/FileOpt/DirOpt) — but the validation/build LOGIC, and the
error shapes (ParamError/MissingParamError/InvalidParamError), are
shared. ports' own FilePathParamError/DirPathParamError are the one
exception, kept as their own distinct types rather than aliased, since
their LogValue() keys predate and differ from codex.Param's.
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, V]/NewOptionalResponseHeaderParam[Resp, V]
(+ Cookie equivalents, also generic over V — a typed response header/
cookie merges directly into an int/uuid.UUID/etc. exactly like the
request-side constructors above) 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.Call/rest.CallWithTransport 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]'sCheckreceives the FULLY DECODEDDateRange— it runs AFTER every per-fieldRequired/Codec/nested-struct check has already succeeded, so a missing/invalid individual field is reported first (per-fieldValidationErrors), 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 otherRefined 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/OptionalFielddeclaration 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
ErrMissingFieldfor 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. missingcountry) 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.
Recommended key codec: MapCodecValidated (Option B)¶
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:
split(c)→(c.Name, ModuleConfig{c.Image, c.Status})containerKeyCodec.Encode(c.Name)→ validates name →"properties.desired.modules." + c.NamemoduleCodec.Encode(ModuleConfig{...})→{"image":"...","status":"..."}- 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.Validate → ValidationErrors[{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 codec — PartialField
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).
OmitEmptyField/OmitEmptyFieldFunc/OmitDefaultField: omitting a zero-valued field on encode¶
Struct[T]'s Encode unconditionally writes EVERY declared field into its
output map — RequiredField, OptionalField, and DefaultField alike (see
the "Encode note" callout above). PartialField/PartialStruct already
solve "omit unset" — but only by converting every field of the struct to
a pointer, a shape suited to a dedicated "patch" type, not to retrofitting
onto an ordinary value-typed struct just to reduce noise on a handful of
fields. OmitEmptyField/OmitEmptyFieldFunc/OmitDefaultField are a
parallel, opt-in mechanism for exactly that case — no pointer reshape, and
freely mixable with Required/Optional/Default fields in the SAME
Struct(...) call:
type Build struct {
Context string
Dockerfile string
}
var buildCodec = codex.Struct[Build](
codex.OmitEmptyField("context", codex.String(),
func(b Build) string { return b.Context },
func(b *Build, v string) { b.Context = v }),
codex.OmitEmptyFieldFunc("dockerfile", codex.String(),
func(b Build) string { return b.Dockerfile },
func(b *Build, v string) { b.Dockerfile = v },
func(v string) bool { return v == "" }),
)
raw, _ := buildCodec.Encode(Build{Context: "./app"})
// raw == map[string]any{"context": "./app"} — "dockerfile" key is ABSENT, not "".
Hard usage rule — not a soft suggestion. Comparing a field's current
value to Go's zero value to decide "omit it" is fundamentally ambiguous
whenever the zero value is ALSO a legitimate, deliberately-chosen value
(e.g. a Retries int field where 0 means "explicitly no retries," not
"never touched") — the exact same long-standing criticism of
encoding/json's omitempty tag. Only use this family for fields whose
own documented type convention already treats the Go zero value as a
first-class "absent" sentinel (this codebase's own convention for
MemLimit==0, Healthcheck==Healthcheck{}, Build.Context==""). For a
field where the zero value is itself meaningful, distinguishable data, use
PartialField/PartialStruct instead — the pointer-based approach is the
only one that can actually make that distinction.
OmitEmptyField[T, F comparable]— the ergonomic shorthand: omits when the current value equalsF's Go zero value via==.OmitEmptyFieldFunc[T, F any]— the escape hatch: an explicitisEmpty func(F) boolpredicate, required wheneverFisn'tcomparable(slices, maps) or "empty" means something other than Go's zero value (e.g. a domain type's ownIsSet()-style check).OmitDefaultField[T, F comparable]— decodes exactly likeDefaultField(absent key → declared default applied) but omits the key on encode whenever the current value equals that SAME declared default — a "minimal diff" round trip. This does not modifyDefaultFielditself, which keeps its existing "always show the resolved value" contract (relied on by real configs that want the effective value explicit) — it's a separate, additive sibling.
Why not just change OptionalField/DefaultField directly? Both are
used across dozens of existing call sites where zero (or the declared
default) is legitimate, already-relied-upon data — silently flipping their
encode behavior would change wire output for every existing caller with no
migration path. Keeping this opt-in, per-field, and separately named avoids
that blast radius entirely — exactly the same reasoning PartialField
already established for staying a parallel mechanism instead of a breaking
FieldCodec/Struct change.
Nil-vs-empty correctness for slices/maps. OmitEmptyFieldFunc's
predicate should compare against nil, not len(s) == 0 — a nil slice
means "the key was never in the wire object" (exactly what OptionalField's
own decode already produces for an absent key), while a non-nil,
zero-length slice ([]string{}) means "the wire explicitly had an empty
array" — a genuinely different state a len()==0 check would incorrectly
conflate. codex.IsZeroValue[F any](v F) bool is a ready-made,
reflection-based predicate that gets this right automatically (it checks
IsNil() for slices/maps/pointers, not length) — pass it explicitly to
OmitEmptyFieldFunc when you don't want to hand-write s == nil yourself:
This is go-codex's only use of reflection, and it is entirely opt-in —
never a hidden default. Struct's own field constructors
(RequiredField/OptionalField/DefaultField) and this family's own
zero-value shorthand (OmitEmptyField[T, F comparable]) use plain ==
comparison, no reflection at all; codex.IsZeroValue exists purely as a
convenience a caller may choose to pass to OmitEmptyFieldFunc's isEmpty
parameter. This deliberately does NOT become OmitEmptyField's automatic
default behavior for non-comparable types — a blind structural zero-check
knows nothing about a type's own business meaning of "empty" (e.g. a
domain type whose IsSet() checks only ONE of its fields, not the whole
struct), so an implicit reflect-based default could silently disagree with
that type's own convention.
Interaction with Template/DottedKeyCodec/DecodeVars/EncodeVars.
FieldCodec[T] has four consumers besides Struct/StrictStruct —
DottedKeyCodec, Template's NewTemplate/Fields(), and
DecodeVars/EncodeVars. None of them route through Struct's Encode
loop — they call a field's plain encode() method directly. A sparse
field's plain encode() ALWAYS encodes the current value, exactly like
OptionalField — it deliberately does NOT consult isEmpty. Only
encodeSparse() (consulted exclusively by Struct's own type-assertion
check) honors the omit rule. This guarantees a path/topic/dotted-key var
declared with OmitEmptyField never silently goes missing from a
URI/topic/key build merely because its value happened to be zero — in
practice this scenario shouldn't arise, since path/topic template vars are
conventionally always-required, but the guarantee holds regardless.
No new structured error types and no observer integration are needed:
these constructors reuse whatever errors the per-field Codec[F] already
produces (ValidationError/ValidationErrors, exactly like Struct), and
are pure codec-construction primitives with no I/O.
Maybe[T]: definitive presence tracking¶
OmitEmptyField/OmitEmptyFieldFunc (above) solve "omit a zero-valued
field on Encode" via a HEURISTIC: compare the current value to Go's zero
value (or a custom predicate). This heuristic is fundamentally UNABLE to
distinguish two states that are byte-identical in Go's memory model: "this
field was never touched" vs. "this field was explicitly set to exactly the
zero-equivalent value" (e.g. a caller who genuinely means Retries: 0,
Nick: ""). codex.Maybe[T] — go-codex's Go rendering of Haskell's
Maybe/Rust's Option — is a lightweight, PER-FIELD alternative: a small
value type pairing a T with an explicit "was this ever set" bit
(Just/Nothing), usable as the type of ONE struct field (not the whole
struct) — giving OmitEmptyField-style omission a DEFINITIVE signal
instead of a heuristic, without forcing every OTHER field in the struct to
become a pointer too (contrast with PartialField/PartialStruct, which
solves this same ambiguity but requires an ALL-pointer struct reshape).
Maybe[T] does NOT replace OmitEmptyField/OmitEmptyFieldFunc. They
remain the right choice for the common case — a field whose zero-means-
absent convention is already safe and documented (as with every field in
dockercompose.Service). Maybe[T] is the precise escape hatch for the
genuinely ambiguous cases where that heuristic isn't good enough.
type Profile struct {
Name string
Nick codex.Maybe[string]
}
var profileCodec = codex.Struct[Profile](
codex.RequiredField("name", codex.String(),
func(p Profile) string { return p.Name },
func(p *Profile, v string) { p.Name = v }),
codex.MaybeField("nick", codex.String(),
func(p Profile) codex.Maybe[string] { return p.Nick },
func(p *Profile, v codex.Maybe[string]) { p.Nick = v }),
)
var p Profile
p.Nick.Set("") // explicitly set to "" -- NOT the same as never touched
raw, _ := profileCodec.Encode(p)
// raw == map[string]any{"name": "", "nick": ""} -- "nick" key IS present,
// because it was explicitly Set, even though its value is the zero value.
// A never-touched p.Nick (Maybe[string]{}) would omit the "nick" key
// entirely -- the exact distinction OmitEmptyField cannot make.
Maybe[T]'s API: Just(v)/Nothing[T]() construct a set/unset value;
Set(v) is REPEATABLE (every call overwrites — contrast with
Immutable.Set's exactly-once contract); Get() NEVER panics (returns
T's zero value when unset — contrast with Immutable.Get, which panics
before the first Set); IsSet()/TryGet() are the explicit-presence-
check siblings. Four combinators round out the type for working with it
functionally: MaybeMap[T,R](m, fn) (a free function, not a method — Go
generic methods cannot introduce a new type parameter R, the same
constraint forge.NewFunction[In, Out] already documents elsewhere;
applies fn only if set, Nothing in → Nothing out) — named MaybeMap
rather than the shorter Map to avoid colliding with the existing
codex.Map[K,V] map-codec constructor; MaybeFlatMap[T,R](m, fn) (also a
free function, same reasoning) chains a Maybe-returning function
instead — Haskell's >>=/Rust's and_then — for the case where the
transformation ITSELF might not produce a value (e.g. parsing a
Maybe[string] into a Maybe[int], where the string might not parse): a
Just input can still yield Nothing if fn does, unlike MaybeMap,
whose fn always returns a plain R; OrElse(fallback) (the safe-
default-value idiom, Rust's unwrap_or/Haskell's fromMaybe); Filter(pred)
(narrows "set" down to "set AND satisfies some condition," returning
Nothing otherwise).
Maybe[T] the TYPE has no ambient codec — but MaybeCodec[T] derives
one, symmetric with Either2. Unlike Immutable/Mutable (which both
REQUIRE a Codec[T] at construction), a bare Maybe[T] value carries no
codec of its own — a zero-initialized Maybe[V]{} is already meaningful
(Nothing) with no constructor call needed at all. This mirrors Either
exactly: the bare Either[A,B] struct ALSO has no ambient codec — you
always derive one from inner codecs (Either2(ca, cb)). MaybeCodec[T](inner
Codec[T]) Codec[Maybe[T]] is Maybe's exact counterpart to Either2 —
both are plain, general codec-derivation functions, usable with ANY
composer (RequiredField, OptionalField, SliceOf, Map, or
standalone), not just via a dedicated field constructor:
codex.MaybeCodec(codex.String()).Decode("hi") // Just("hi")
codex.SliceOf(codex.MaybeCodec(codex.Int())) // Codec[[]Maybe[int]]
codex.RequiredField("note", codex.MaybeCodec(codex.String()), get, set)
// -- decodes Just/Nothing correctly, but Encode ALWAYS shows the key
// (Nothing renders as "", the inner codec's zero value) -- no omission.
// Use MaybeField instead when omission-on-Nothing is what you want.
MaybeField is a documented, provable special case of this general
composition — MaybeField(name, codec, get, set) behaves EXACTLY like:
codex.OmitEmptyFieldFunc(name, codex.MaybeCodec(codec), get, set,
func(m codex.Maybe[V]) bool { return !m.IsSet() })
(TestMaybeField_EquivalentToOmitEmptyFieldFuncPlusMaybeCodec proves this
by constructing a Struct BOTH ways and asserting identical Encode
output for every presence state.) This is the exact same relationship
StringOrInt64() already has to Either2(String(), Int64()) — a named,
common-case convenience built directly on a general composition, not a
separate mechanism. MaybeField's own internal implementation is left
as its existing, already-tested hand-written form (not literally rewritten
to call OmitEmptyFieldFunc) — the equivalence is proven by test, not by
sharing code, avoiding churn to working code for a purely cosmetic reason.
How does MaybeField render in Schema? IDENTICALLY to a plain
OptionalField of the same type — no oneOf/nullable/any marker
distinguishing "definitively presence-tracked" from "always-shown
optional." Verified by an actual render:
codex.MaybeField("nick", codex.String(), get, set)
// Schema -> {"Type": "string"} -- indistinguishable from
// codex.OptionalField("nick", codex.String(), get, set)'s OWN schema,
// and NOT present in the Struct's "Required" array.
This is correct, not a gap. MaybeCodec[T]'s Schema field is
literally inner.Schema, untouched, and sparseField.schema() (shared by
every sparse-family constructor — OmitEmptyField/OmitDefaultField/
MaybeField alike) always reports Required=false. The WIRE contract
genuinely is identical between MaybeField and OptionalField for the
same V — a valid document either omits the key or includes V's shape,
in BOTH cases. Just/Nothing vs. "Go zero value" is a Go-side,
POST-decode distinction with no wire-visible difference to describe — a
schema correctly stays silent about a distinction that doesn't exist on
the wire. (Contrast with EitherField's schema, which DOES differ
visibly from a plain field's — see the Either ergonomics section below.)
Why not just use a pointer (*T), like PartialField does? A fair
question, since PartialField/PartialStruct already ship and already
solve this ambiguity definitively via pointers. Pointers remain the right
tool for a DEDICATED patch/partial-update type where EVERY field is
independently optional — the all-pointer reshape is the point there, not a
cost. But reusing bare pointers as the GENERAL per-field answer has real
downsides Maybe[T] avoids:
- No struct reshape required. Making one ordinary domain field's type
*stringinstead ofstringtouches every existing read site of that field (arithmetic, string ops, anything not expecting a pointer) — real, spread-out churn.Maybe[T]only changes what the FIELD wraps, not what every consumer must additionally nil-check. - Safe-by-default, not panic-by-forgetting. Every pointer read site
must remember to nil-check or safely dereference.
Maybe[T]'sGet()is ALWAYS safe (zero value, never panics); the presence check (IsSet()/TryGet()) is the deliberate opt-in, inverting the usual pointer footgun. - No aliasing. Copying a struct with pointer fields means BOTH copies
share the same underlying value — mutating through one pointer silently
affects the other.
Maybe[T]carriesTBY VALUE inside the wrapper; copying aMaybe[T]-containing struct copies the value too, exactly like every other value-typed field in Go. - No extra heap allocation. Each non-nil pointer is a separate heap
allocation;
Maybe[T]{value T; set bool}storesTINLINE — no additional indirection beyond the struct's own layout, a real difference under load for structs with many optional fields (e.g. a hot decode path). - No collision with
Nullable[T]'s existing meaning for*T. go-codex already uses*T(viacodex.Nullable[T]) for a DIFFERENT axis: wire-level explicitnullvs. a real value. Reusing bare pointers ALSO for "was the Go field ever assigned" would overload one Go pattern with two different meanings depending on context.
A concrete, already-shipped example of this exact ambiguity: re-read
the OptionalField+Nullable example earlier in this doc —
// "note" absent → Note == nil (key was not in the object)
// "note": null → Note == nil (key was present, value was null)
OptionalField+Nullable cannot distinguish "absent" from "explicit
null" either — BOTH produce Note == nil today (only RequiredField+
Nullable avoids this, since a required key is guaranteed present). This
is the exact same shape of ambiguity this section is about, just on the
"wire-null vs. absent" axis instead of "zero-value vs. absent."
One deferred idea remains, not designed here — see
docs/roadmap/maybe-nullable-and-codec.md for a short "idea only" sketch:
a THREE-state Maybe[Nullable[T]] composition (absent / present-null /
present-value — the exact shape RFC 7396 JSON Merge Patch needs) that
could resolve the ambiguity above (already VERIFIED to compose today with
zero new code — MaybeField("x", codex.Nullable(inner), ...) on a
Maybe[*T] field genuinely distinguishes all three states; what's left
is a worked doc example, not a new primitive). The doc's OTHER idea — a
general-purpose public codex.Codec[Maybe[T]] — has SHIPPED as
MaybeCodec[T] above.
Also see docs/roadmap/optional-mutable.md for OptionalMutable[T]
— a DIFFERENT, NOT-yet-built idea (a Mutable[T] that may start unset)
that sounds superficially similar to Maybe[T] but solves an opposite
problem (a value's currentness over time, vs. Maybe's provenance at a
point in time) — see that doc's own comparison table.
Interaction with Template/DottedKeyCodec/DecodeVars/EncodeVars.
MaybeField is built on the SAME sparseField[T,F] machinery as
OmitEmptyField (see that subsection's own "Interaction with..." note) —
so it inherits the identical caveat. FieldCodec[T] has four consumers
besides Struct/StrictStruct (DottedKeyCodec, Template's
NewTemplate/Fields(), and DecodeVars/EncodeVars), and none of them
route through Struct's Encode loop — they call a field's plain
encode() method directly. A MaybeField-declared field's plain
encode() ALWAYS encodes the current value (calling Maybe[V].Get(),
which returns V's zero value for Nothing) — it deliberately does NOT
consult IsSet(). Only encodeSparse() (consulted exclusively by
Struct's own type-assertion check) honors the omit rule. This guarantees
a path/topic/dotted-key var declared with MaybeField never silently goes
missing from a URI/topic/key build merely because it was never Set — in
practice this scenario shouldn't arise, since path/topic template vars are
conventionally always-required, but the guarantee holds regardless.
No new structured error types and no observer integration are needed:
Maybe[T]'s own Set/Get are infallible, and decode errors flow
through the enclosing field's Codec[V], exactly like OmitEmptyField.
Which presence tool do I use? A decision guide¶
Five different mechanisms in this codebase all touch "is this field present/optional" in some way. They are NOT competing — each answers a different question:
| Tool | Distinguishes absent from zero? | Struct reshape needed? | Extra allocation? | Use when... |
|---|---|---|---|---|
OptionalField |
No — zero value on absent, ALWAYS encoded | None | None | The field is genuinely optional AND its zero value is fine to always show on re-encode |
OmitEmptyField/OmitEmptyFieldFunc |
No (heuristic — zero value is TREATED as absent) | None | None | Zero value is ALREADY a safe, conventional "absent" sentinel for this field (a documented codebase convention) — the common case |
Maybe[T] |
Yes, definitively | None (only the ONE field's type changes) | None (value-typed, inline) | You need to tell "never touched" from "deliberately zero" for one or a few fields, without reshaping the whole struct |
Nullable[T] (+ RequiredField) |
Distinguishes wire null from a real value — a DIFFERENT axis, not absence |
None | One pointer per field | You need wire-level explicit null vs. a value; combine with RequiredField if "absent" must also be impossible |
PartialField/PartialStruct |
Yes, definitively, for EVERY field at once | Whole struct becomes all-pointer (a dedicated "patch" type) | One pointer per field | You're building a dedicated PATCH/partial-update type where EVERY field is independently optional |
Start with OmitEmptyField — it's the cheapest and matches most real
zero-means-absent conventions already documented in a codebase (as
dockercompose.Service demonstrates for all 14 of its fields). Reach
for Maybe[T] only once you've concretely hit the ambiguity it solves —
don't reach for it defensively on every field "just in case." Use
PartialField when the WHOLE struct is a patch/partial-update BY
DESIGN, not for one-off fields on an otherwise-ordinary domain struct.
Nullable[T] solves a different problem (wire null) entirely and
composes with any of the above rather than competing with them.
Caveat: none of these belong on a path/topic param's merge Field¶
rest.NewPathParam[T, V]/events.NewTopicParam[T, V]/
reqreply.NewTopicParam[T, V] (and their shared foundation,
codex.NewParam[T, V]) always build their merge Field as a plain
RequiredField — there is no constructor argument to swap in
OmitEmptyField/MaybeField. A caller COULD hand-construct a
codex.MergedParam[T]{Field: ...} with one of these anyway (Field is
just an exported codex.FieldCodec[T]), but it would be inert:
OmitEmptyField/MaybeField— decode never sees "absent", because a route/channel only matches when the path/topic segment is present in the first place; andcodex.EncodeVars(used to build outgoing path/topic vars) always calls a field's plainencode(), neverencodeSparse(), so the omission logic never fires either. Net effect: byte-for-byte identical toRequiredField, just with pointlessMaybe[V]/sparse ceremony. This isn't a bug — it's confirmation that a path/topic variable structurally can't be "absent" (unlike a JSON body key): the segment is either present-and-matched, or the route/channel never matched at all. This holds regardless of V — typed params (see below) don't change this.EitherFieldis the one that DOES make sense here — a path/topic segment legitimately can be "one of two shapes" (e.g. a UUID-slug OR a numeric-string ID, mirroring Kubernetes'IntOrStringbut in a URL/topic segment). It composes becauseEitherFieldis justRequiredField(name, Either2(ca, cb), get, set)— same "always present" shape as a plain path param, just with aoneOfcodec underneath. Requirement: BOTHca/cbmust decode from/encode to a Gostring(codex.DecodeVars/EncodeVarsbox every path/topic var asstring) — e.g.Either2(codex.String().Refine(isSlug), codex.IntString())now works (see below), whereas plaincodex.Int()still doesn't (it only acceptsfloat64/int/int64, never a raw string).- A "
Maybethat errors instead of returningNothing" isn't a gap to fill either — that's justRequiredField's existing behavior (decode fails when the key is absent).MaybeField's entire purpose is safe optionality; making it throw onNothingwould defeat that purpose.
Typed (non-string) path/topic/query/header/cookie/key vars¶
codex.DecodeVars/codex.EncodeVars box every var value as a plain Go
string on the wire — that's the ONLY hard requirement. Any Codec[V]
that decodes from a boxed string and encodes back to a string composes
with RequiredField/OptionalField, and therefore with codex.NewParam
and every per-boundary constructor built on it
(rest.NewPathParam/NewRequired/OptionalQueryParam/
NewRequired/OptionalHeaderParam/NewRequired/OptionalCookieParam,
events.NewTopicParam, reqreply.NewTopicParam,
ports.NewFilePathParam, ports.NewCacheKeyParam — all of them generic
over V, not hardcoded to Codec[string]). This lets a path/topic/query/
header/cookie/key variable merge directly into an int, a uuid.UUID, or
any other typed field instead of a bare string the handler has to
re-parse by hand:
rest.NewPathParam("id", codex.IntString(),
func(r GetUserReq) int { return r.ID },
func(r *GetUserReq, v int) { r.ID = v },
)
Two primitives in codex build such a Codec[V]:
codex.StringCodec[V](parse, format, schema)— explicit, zero magic: you supply thestring → Vparse function and theV → stringformat function directly. Use for anything without a pre-existing text form, or when you want full control (custom error wrapping, a stricter schema, etc.).codex.TextCodec[V]()— zero boilerplate for anyVwhose pointer already implementsencoding.TextMarshaler/encoding.TextUnmarshaler(the standard Go convention for "this type has a canonical string form"), e.g.uuid.UUIDfromgithub.com/google/uuid:codex.TextCodec[uuid.UUID]().
Ready-made convenience codecs built on StringCodec for the common stdlib
cases: codex.IntString(), codex.Int64String(), codex.UintString(),
codex.BoolString().
Internally, codex.NewParam derives the param's spec-level Codec[string]
(used by ValidateParams/BuildFromParams, which only ever see the raw
wire string) from the typed codec via the exported
codex.StringValidatorFrom[V] helper — it parses the wire string through
codec to apply V's constraints, then discards the typed result and
returns the original string. An invalid path segment (e.g. "abc" for an
IntString()-typed param) is rejected at spec/pre-flight validation time,
before Decode ever runs — identical behavior to a plain
codex.String().Refine(...)-typed param, just generalized to any V.
Getter/Setter: validated value containers built on Codec[T]¶
Everything above validates runtime values passed through Encode/
Decode. A separate, smaller need: giving a compile-time-authored
CONSTANT (a fixed path template, a protocol version string) or a
runtime value SET EXACTLY ONCE (a config/env var loaded once at
startup) — or reassigned REPEATEDLY at runtime, re-validated on every
reassignment (a rotating credential), optionally with an explicit
validity window (a memoized, TTL/invalidate-able value) — the same
declarative validation, instead of leaving it a bare, unchecked literal
or variable. codex.Getter[T]/Setter[T]/FreshGetter[T] (declared
standalone in codex/getter.go, so future codec-backed containers can
satisfy them too) and their four implementations, Const[T]/
Immutable[T] (codex/const.go), Mutable[T] (codex/mutable.go),
and Cacheable[T] (codex/cacheable.go), are a THIRD layer sitting on
top of the two that already exist:
| Layer | What it validates | Reference |
|---|---|---|
0 — Codec[T] |
A wire SHAPE, on every Encode/Decode call |
codex/codec.go |
1 — HasCodec[T] |
"This TYPE knows its own Codec" — generic helpers (Validate/New/EncodeSelf/DecodeAs/SchemaOf) work on any type implementing it |
codex/hascodec.go |
2 — Getter[T]/Setter[T]/FreshGetter[T] + Const[T]/Immutable[T]/Mutable[T]/Cacheable[T] |
"This VALUE's identity, at a specific point in its OWN lifecycle (authored-at-compile-time / assigned-once-at-runtime / reassigned repeatedly at runtime / reassigned with an explicit validity window), is validated" — generic CONTAINERS parameterized by an externally supplied Codec[T] |
codex/const.go, codex/mutable.go, codex/cacheable.go |
Not literally "higher-kinded" — Go generics have no abstraction over
type constructors, only over concrete types — but this table captures
a genuinely reusable recipe: wrap a Codec[T], add ONE lifecycle rule,
expose Getter/Setter. Nothing new is invented at the codec level:
both consumers below validate their incoming value via the SAME
Codec[T].Validate every other part of go-codex already uses.
type Getter[T any] interface{ Get() T }
type Setter[T any] interface{ Set(T) error }
type GetterSetter[T any] interface {
Getter[T]
Setter[T]
}
Const[T] — compile-time-authored, eagerly validated, PANICS if
invalid (mirrors Must/Must2's existing panic-on-invalid convention
for authored, not runtime, input):
var pathPatternCodec = codex.String().Refine(validate.NonEmptyString)
var useCasePathPattern = codex.MustConst(
fmt.Sprintf("%s/{%s}.json", useCasesDirName, useCaseNameVar),
pathPatternCodec,
)
useCasePathPattern.Get() // "usecases/{usecase_name}.json"
useCasePathPattern.String() // same, via fmt.Sprint — works for any T
Const[T] implements ONLY Getter[T] — its value is fixed forever at
construction, so there is no runtime "assign" to expose. It is a plain
value type (safe to copy freely, always fully valid).
Immutable[T] — runtime-supplied, validated exactly once, at the
ONE Set call — the "config/env var loaded once at startup, then
read-only for the rest of the process" shape. config.FromEnv (see
Feature: Config) is now the batteries-included
way to get there for a whole struct-shaped config, returning the loaded
value ALREADY wrapped in a fresh *Immutable[T]:
appConfig, err := config.FromEnv(configCodec, "APP_")
if err != nil {
log.Fatal(err) // real external input — an error, never a panic
}
// ... elsewhere, for the rest of the process's lifetime:
cfg := appConfig.Get()
For a single scalar value (not a whole struct), construct an
Immutable[T] directly and Set it from config.FromEnvVar's result
(or os.Getenv) yourself — FromEnvVar stays plain-value on purpose
(see config.FromEnvVar's own doc comment for why):
var apiBaseURL = codex.NewImmutable(codex.String().Refine(validate.NonEmptyString))
func main() {
if err := apiBaseURL.Set(os.Getenv("API_BASE_URL")); err != nil {
log.Fatal(err) // real external input — an error, never a panic
}
// ... elsewhere, for the rest of the process's lifetime:
url := apiBaseURL.Get()
}
Immutable[T] implements GetterSetter[T] — both Getter[T] and
Setter[T] — and, unlike Const[T], has a genuine "not yet set"
lifecycle state before the first Set, so it is used via a pointer
(*Immutable[T]) and guards its state with a mutex:
Setvalidates via the SAMEcodec.Validateand returns an error (not a panic) on an invalid value — real runtime input, not an authored literal.- A SECOND
Setcall always fails withImmutableAlreadySetError, regardless of whether the new value would itself be valid — "set once" is enforced, not just documented. Get()PANICS if called before any successfulSet(a real bug: reading config before startup finished loading it) —TryGet() (T, bool)is the safe, non-panicking alternative for optional/defensive access.
This asymmetry (Const[T] is Getter[T]-only; Immutable[T] is a full
GetterSetter[T]) documents the conceptual difference directly:
Getter[T] alone means "this value's identity is already settled";
Getter[T] + Setter[T] together means "this value's identity can
still be settled, exactly once, by someone." No structured error type
is needed beyond ImmutableAlreadySetError (empty — nothing to report
beyond the fact itself) and no observer integration applies (no I/O, no
adapter boundary — MustConst/Set each run once, not on a repeated
request/response path).
Mutable[T] — runtime-supplied, validated at EVERY Set call (not
just once) — the "config that can be hot-reloaded without a restart"
shape (a rotating JWKS key set, a rotating shared API key). Unlike
Immutable[T] (set exactly once, panics on a repeat Set), Mutable[T]
always holds a valid value from construction onward:
keys, err := codex.NewMutable("jwks-signing-keys", "key-v1",
codex.String().Refine(validate.NonEmptyString))
if err != nil {
log.Fatal(err) // real external input — an error, never a panic
}
// A SecurityFunc-shaped closure always reads the CURRENT key:
current := keys.Get() // never panics — construction guarantees validity
// A background rotation loop (e.g. a JWKS refresh ticker) calls Set —
// re-validated, last-good-value-wins on an invalid Set:
if err := keys.Set("key-v2"); err != nil {
// current value is UNCHANGED — the bad key never took effect
log.Warn("rotation rejected", "err", err)
}
Get()NEVER panics — construction (NewMutable) requires a validinitialvalue, so there is no "unset" state to guard against, unlikeImmutable[T]; noTryGetneeded either.- Every valid
Setsucceeds and REPLACES the current value — no "already set" concept at all (the opposite ofImmutable[T]'s exactly-once rule). An invalidSetleaves the current value UNCHANGED (last-good-value-wins) and returns the codec's own validation error — the SAME error shape every otherCodec[T].Validatecall already produces, no new error type. - Guarded by
sync.RWMutex(notsync.Mutex) — reads (Get) vastly outnumber writes (Set) for this container's intended use (every request/message reads the current key material; reloads happen on a schedule measured in minutes/hours). WithReloadObserverwires in an optional Observer whoseRecordReloadfires on everySet, success or failure — the first container in this family with an observable lifecycle event.codex.ReloadObserveris defined LOCALLY incodex(codex/observer.go), not instats—codexhas zero dependency onstats(deliberately;statsdepends oncodex, and the reverse would form a real import cycle). Anystats.ObserverCONCRETE implementation that also definesRecordReload(stats.NoopObserver/LoggingObserver/the internal fanout type all do) satisfiescodex.ReloadObserverSTRUCTURALLY, with zero import needed in either direction —statsexposes the same interface asstats.ReloadObserver(a type alias) plusstats.AsReloadObserver(obs)to bridge an existingstats.Observervalue into this position:
keys, err := codex.NewMutable("jwks-signing-keys", "key-v1", codec,
codex.WithReloadObserver[string](myObserver)) // any value satisfying RecordReload
Cacheable[T] — a 4th sibling, adding a validity WINDOW on top of
Mutable[T]'s re-validating Set shape: a TTL, an explicit
Invalidate() call, or both. Where Mutable[T] answers "what's the
current value?", Cacheable[T] answers "what's the current value, and
can I still trust it?" — the in-process, stale-while-revalidate
memoization shape (an expensive computation, or any SecurityFunc/
CredentialFunc result a process wants to avoid recomputing too often):
memo, err := codex.NewCacheable("expensive-computation", initial, codec, time.Hour)
if err != nil {
log.Fatal(err)
}
value, fresh := memo.Get() // never panics — same guarantee as Mutable[T].Get
if !fresh {
// stale-while-revalidate: serve value anyway, refresh in the background
go func() {
if v, err := recompute(); err == nil {
memo.Set(v)
}
}()
}
// An upstream event (webhook, Redis keyspace notification) can mark the
// value stale BEFORE its TTL naturally expires:
memo.Invalidate()
Get()returns(T, bool)— NOTMutable[T]'s plainT— soCacheable[T]satisfiescodex.FreshGetter[T](Get() (T, bool)), a THIRD sibling toGetter[T]/GetterSetter[T], instead of forcingCacheable[T]'s two-value shape ontoGetter[T].Cacheable[T]still satisfies plainSetter[T]unchanged.IsStale()reports staleness without reading the value — for a caller that wants to decide whether to refresh before paying for aGet().SetreusesMutable[T]'s exactcodex.ReloadObserver.RecordReloadevent unchanged.Invalidate()fires a SEPARATEcodex.InvalidateObserver.RecordInvalidate(location)— kept separate fromRecordReloadbecause an explicit invalidation is not a failed reload (conflating the two would make "how manySetcalls failed validation" and "how many times was this invalidated" indistinguishable in a dashboard). A caller observingMutable[T]alone is never forced to implementRecordInvalidate— it's checked with its own, independent type assertion, exactly likeReloadObserver's.stats.InvalidateObserver/stats.AsInvalidateObservermirrorReloadObserver's shipped shape exactly.- Every
Mutable[T]is functionally aCacheable[T]with an infinite TTL and no invalidation —Cacheable[T]adds a validity window on top ofMutable[T]'s shape, not a separate mechanism underneath.
Const[T] |
Immutable[T] |
Mutable[T] |
Cacheable[T] |
|
|---|---|---|---|---|
Re-Set-able? |
Never | Once | Unlimited | Unlimited (same as Mutable) |
Invalid Set |
Panics (MustConst) — or returns an error (NewConst) |
Returns a typed/codec error | Current value UNCHANGED, returns codec error | Same as Mutable — last-good-value-wins |
| Notion of time/staleness? | No | No | No — a value is just "current," forever, until replaced | Yes — TTL and/or explicit Invalidate(); a value can be present but stale |
Get() return shape |
T |
T (panics if unset) |
T (never panics) |
(T, bool) — value + freshness flag, never panics |
| Interface satisfied | Getter[T] only |
GetterSetter[T] |
GetterSetter[T] |
FreshGetter[T] + Setter[T] |
| Observer | None | None | ReloadObserver.RecordReload on Set |
Same ReloadObserver for Set, PLUS InvalidateObserver.RecordInvalidate for Invalidate() |
| Typical driver | Path/topic pattern constants | Config/env var loaded once at startup | Rotating security credentials — "give me whatever is current" | Stale-while-revalidate memoization — "give me what you have, tell me if it's still good" |
Composing with adapters — SecurityFunc/CredentialFunc need ZERO new
API. nethttp/chi's Options.SecurityFunc (server) and
CallOptions.CredentialFunc (client) — mirrored by mqtt/mqtt5's own
SecurityFunc/CredentialFunc — are already plain closures. A caller
captures a *Mutable[T]/*Cacheable[T] and calls .Get() INSIDE the
closure body, exactly like capturing any other variable — no dedicated
SecurityFuncFromMutable-style constructor exists, or is planned,
because one would only save a line or two over the closure itself while
adding permanent API surface. A plain static value, a hand-rolled cache,
or one of these two containers are all EQUALLY valid choices for the
SAME SecurityFunc/CredentialFunc field — nothing about the route,
channel, or builder ever knows or cares which one a caller picked:
// SERVER — Mutable[T]: a background rotation loop calls keys.Set on a
// schedule; SecurityFunc always sees the CURRENT key.
opts := nethttp.Options{
SecurityFunc: func(_ context.Context, r *http.Request, _ []route.SecurityRequirement) error {
current := keys.Get() // ← read INSIDE the closure, on every request
if !validSignature(r, current) {
return errors.New("signing key mismatch")
}
return nil
},
}
// CLIENT — Cacheable[T]: a TTL-bearing credential cache. The freshness
// bool from Get() must be handled explicitly — Cacheable never hides it.
credentialFunc := func(ctx context.Context, reqs []route.SecurityRequirement) (http.Header, error) {
val, fresh := cred.Get() // ← read INSIDE the closure, on every call
if !fresh {
val = fetchFreshCredential(ctx) // caller's own refresh — see below
if err := cred.Set(val); err != nil {
return nil, err
}
}
h := make(http.Header)
h.Set("Authorization", "Bearer "+val)
return h, nil
}
The one real gotcha: .Get() MUST be called inside the closure body,
on every invocation — never hoisted to a local variable at construction
time (current := keys.Get(); opts.SecurityFunc = func(...) { ... use current ... }).
Hoisting silently freezes the value forever, defeating the entire point
of using a live container instead of a plain static one — the closure
would keep verifying against the FIRST key forever, even after a real
rotation. See examples/mutable-security-keys for a full runnable demo
wiring both containers into REAL nethttp server/client hooks (not just
simulated closures), including the Cacheable[T] case's manual
fetch-on-stale + CallOptions.OnCredentialRejected → cred.Invalidate
wiring (mirroring nethttp.NewCachingCredentialFunc's own invalidate-on-401
pattern). Fully AUTOMATING that fetch-on-stale step — so a caller never
writes the if !fresh { ... } branch at all — is
ports.RefreshingCacheable[T]'s job (GetOrRefresh(ctx) (T, error)), a
separate, not-yet-shipped roadmap item that COMPOSES Cacheable[T]
rather than replacing it.
Constructor naming convention. Every Getter/Setter-family type
(Const, Immutable, Mutable, Cacheable, and the planned
OptionalMutable) follows the same naming pattern: MustX validates and
PANICS (authored input, invalid = a bug); NewX validates and returns an
ERROR (real runtime input, invalid = an expected condition); Set(value)
error on a GetterSetter[T] type revalidates against the SAME codec on
EVERY call.
This is a documented CONVENTION, not a Go interface requirement — Go has
no static/class methods, so a literal "Factory" interface can't express
"a type that can be constructed" the way it might in other languages.
See .github/instructions/go-codex.instructions.md's "Constructor Naming
Convention for Getter/Setter-Family Types" section for the full
convention (including Maybe[T]'s documented exception: its Set is
infallible by design, since it has no codec of its own).
Why isn't every struct field validated-on-assignment like Mutable[T]
is? Deliberately not: Mutable[T]'s mutex + method-call-only
access + per-field heap allocation would be a severe cost applied to the
overwhelming common case (a value decoded once, read many times). go-codex
already validates at the BOUNDARY (Codec[T].Decode, once) and trusts the
Go type system from there — the same model virtually every statically
typed program already operates under. Mutable[T]'s real driver is
narrow: values reassigned repeatedly, during a running process, from an
EXTERNAL source that can change without warning (rotating credentials) —
a genuinely different risk profile from an ordinary decoded field. This is
exactly why Const/Immutable/Mutable/Maybe stay OPT-IN, per-field
wrapper types rather than a blanket mechanism — apply the extra protection
selectively, where the guarantee is worth the cost.
Applying a patch: ApplyPatch (flat) vs. ApplyDottedPatch (dotted-path)¶
PartialField/PartialStruct build a validated PATCH value — but say
nothing about how to APPLY it onto a base T. codex.ApplyPatch closes
that gap for the FLAT case (patch fields overwrite the SAME top-level
keys T's own codec declares):
type ProfilePatch struct { Nickname *string; Age *int }
var profilePatchCodec = codex.PartialStruct[ProfilePatch](...)
updated, err := codex.ApplyPatch(currentProfile, profileCodec,
ProfilePatch{Nickname: ptr("bob")}, profilePatchCodec)
// updated.Nickname == "bob"; updated.Age unchanged.
ApplyPatch encodes both base and patch to map[string]any via
their own codecs, deep-merges patch over base (codex.DeepMerge), and
decodes the merged map back through baseCodec — re-running every
Refine constraint on the result.
Dotted-path patches — when a patch key reaches ARBITRARILY DEEP
(e.g. "factory-gw.env.API_URL", not just a top-level field), use
codex.ApplyDottedPatch/ApplyDottedPatchTo instead:
patch := map[string]any{"factory-gw.env.API_URL": "http://new"}
updated, err := codex.ApplyDottedPatchTo(base, baseCodec, patch)
ApplyDottedPatch(base, patch map[string]any) map[string]any is the raw
map-level primitive (no codec involved) — each patch key's FIRST
dotted segment names a top-level key in base; remaining segments (via
codex.BuildDottedPatch) reach deeper via codex.DeepMerge. When a
patch key names a top-level key base doesn't have yet, the patch value
is used WHOLESALE — this is what lets a dotted patch introduce an
entirely NEW entry, not just override fields inside an existing one.
Declaring a codec for a dynamic-dotted-key wire bucket — MQTT-style
templates: if the PATCH itself needs a wire-level codec (not just an
already-decoded map[string]any), and its keys are prefix + dotted-path
(module name unknown ahead of time, e.g. deviceconfig.Patch.EdgeAgent's
own wire bucket), use codex.DottedPatchMapCodec — it declares the key
SHAPE as an MQTT-style TEMPLATE, reusing the exact {varName}/+/#
vocabulary MQTT topic templates already use ("." as the level
delimiter instead of "/"):
var edgeAgentPatchCodec = codex.DottedPatchMapCodec(
"properties.desired.modules.{moduleName}.#",
codex.KeyVarConstraint{Name: "moduleName", Constraint: validate.Slug},
)
{moduleName}captures and validates (via the registeredKeyVarConstraint) exactly one segment.#(as the LAST template segment) matches the remaining path WHOLESALE — zero or more further segments, left OPAQUE — matchingPartialStruct's own untyped-leaf precedent for genuinely dynamic-shape patches. This is what lets a device patch reach a bare module key, one top-level field, or an arbitrarily deep env-var path, all with ONE template.+matches exactly ONE anonymous segment — reach for it instead of#when the shape should NOT go deeper than one more level, e.g."{moduleName}.env.+"accepts exactly one env var name but rejects anything reaching further.
Wildcards are match-ONLY — see DottedKeyCodec
below for the complementary "build ONE typed key from named segments, no
wildcards" case. Typed per-path leaf validation (validate a SPECIFIC
path's value against a SPECIFIC codec) remains a deferred, still-open
idea.
Template[T]: the shared build+match engine underneath every {varName} pattern¶
codex.Template[T] is the single, canonical build+match engine for every
"{varName}"-style pattern in go-codex — REST paths, event/reqreply
topics, MCP resource URIs, file/dir paths, and dotted wire keys (see
DottedKeyCodec below, which is a one-line wrapper over it). Construct
with codex.NewTemplate(pattern, style, fields...), where style is one
of four named values selecting the matching dialect: PathStyle
("/"-delimited, no wildcards — REST/events/reqreply/MCP/file paths),
MQTTStyle ("/"-delimited, +/# wildcards), DottedStyle
("."-delimited, +/# wildcards — dotted wire keys), and GlobStyle
("/"-delimited, glob metacharacters — ports.File/Dir). Template[T]
embeds Const[string] (the raw pattern text, via Get()/String()) and
implements HasCodec[T]: Codec().Encode builds a concrete string from a
T value (via EncodeVars + substitution), Codec().Decode matches a
concrete string against the pattern and decodes the extracted vars into a
T (via DecodeVars). Template[T].Build(vars T) (string, error) is a
convenience wrapper over Codec().Encode for callers who don't need the
full Codec[T] interface. A pattern with NO declared fields (e.g.
Template[struct{}]) is a static, zero-variable pattern — Encode passes
it through unchanged, Decode still confirms an exact match. api/mcp's
Resource[V,T]/ResourceHandle[V,T] are built directly on Template[V];
api/rest/api/events/api/reqreply/ports.File/ports.Dir instead
share the lighter-weight codex.Param/MergedParam[T] primitive (see
above) for their own PathParam/TopicParam/FilePathParam/
DirPathParam — Template[T] is reached for when a boundary's vars
naturally form ONE cohesive type with no separate merge target (like
MCP's resource content), Param/MergedParam[T] when a boundary needs
BOTH a validate-only escape hatch AND an optional merge into an
independently-typed Req/content value.
Template[T].Fields() []FieldCodec[T] returns the template's own declared
fields (mirrors the existing .Vars(), which returns just the names) —
used by callers that need to DECOMPOSE an already-built Template back
into individual field declarations, e.g. mcp.NewResourceFromTemplate
re-declaring them as mcp.URIParam options to delegate to the bare-string
primary constructor (mcp.NewResource), the exact same "escape hatch
decomposes into the primary constructor" idiom rest.NewRouteFromPath/
events.NewChannelFromTopic/ports.NewFileFromPathTemplate already use
via their own public Path.Params/Topic.Params/FilePathTemplate.Params.
codex.IdentityField[V any](name string, codec Codec[V]) Field[V, V] is a
RequiredField with T=F=V and identity get/set — for any single-var
Template[V] where V IS the bare scalar type (not a wrapper struct),
sparing the call site from repeating func(v V) V { return v }/
func(v *V, val V) { *v = val } by hand:
var itemResource = mcp.NewResource[string]("items://{id}", itemCodec,
mcp.ResourceMeta{Name: "Item", MimeType: "application/json"},
mcp.URIParam(codex.IdentityField("id", codex.String().Refine(validate.NonEmptyString))),
)
— matching rest.NewRoute/events.NewChannel/ports.NewFile/
ports.NewDir's exact "bare string + opts" call shape for the common
single-var-resource case, with zero codex.NewTemplate/codex.PathStyle
ceremony needed at the call site (NewResource builds the Template[V]
internally from the declared URIParams). Reach for
mcp.NewResourceFromTemplate (taking a pre-built codex.Template[V]
directly) only when reusing the SAME URI shape across multiple Resource
declarations of different content types.
DottedKeyCodec/DottedPatchMapCodec: MQTT-style dotted-key templates¶
go-codex already has an MQTT topic-matching engine
(internal/templatematch.MatchMQTTWildcard). MatchDottedWildcard
adapts the identical algorithm with "." as the level delimiter — giving
dotted wire keys the SAME declarative vocabulary MQTT topics already
have. Since wildcards (+/#) only make sense for MATCHING an
already-existing key (not building exactly one new key from named
values), this splits into two constructors:
// DottedKeyCodec: named-vars-ONLY template (no wildcards — panics
// otherwise). Builds ONE typed key K via the SAME DecodeVars/EncodeVars/
// FieldCodec machinery PathParam/TopicParam already use.
var moduleKeyCodec = codex.DottedKeyCodec("properties.desired.modules.{tenant}.{name}",
codex.RequiredField("tenant", codex.String().Refine(validate.Slug),
func(k ModuleKey) string { return k.Tenant },
func(k *ModuleKey, v string) { k.Tenant = v }),
codex.RequiredField("name", codex.String().Refine(validate.Slug),
func(k ModuleKey) string { return k.Name },
func(k *ModuleKey, v string) { k.Name = v }),
)
// Compose with Map[K,V]/EntrySlice for a fully-typed dotted-key map:
var modulesCodec = codex.Map[ModuleKey, ModuleConfig](moduleKeyCodec, moduleConfigCodec)
DottedKeyCodec generalizes the "prefix + a fixed, known number of
dotted segments → a struct key" pattern (previously hand-rolled per
package, e.g. examples/flat-key-patch's former twoPartKeyCodec) with
a template-string API instead of numSegments+build/split closures —
and reuses DecodeVars/EncodeVars rather than inventing a new
mechanism. A template with exactly ONE {var} covers PrefixedKeyCodec's
own "prefix + name" shape too — e.g. examples/go-edge-models's
manifesttemplate.ModuleNameCodec/RouteNameCodec:
type ModuleName string
var moduleNameField = codex.RequiredField("name", codex.String().Refine(validate.Slug),
func(n ModuleName) string { return string(n) },
func(n *ModuleName, s string) { *n = ModuleName(s) },
)
var ModuleNameCodec = codex.DottedKeyCodec[ModuleName](
"properties.desired.modules.{name}", moduleNameField,
)
// Same wire shape/validation/errors PrefixedKeyCodec produces — one
// FieldCodec instead of a bare Constraint, reusing the SAME template
// mechanism the package's other (multi-segment/wildcard) dotted keys use.
PrefixedKeyCodec stays available and is NOT removed — it remains
the more minimal constructor for "prefix + exactly one segment, whole
rest wrapped as one named string type" when a package has no OTHER
dotted-key needs (no FieldCodec ceremony, just a Constraint). Reach
for DottedKeyCodec's single-{var} form instead when the same package
already uses DottedKeyCodec/DottedPatchMapCodec for other keys and
you want one consistent template-based vocabulary throughout. See
docs/guides/wire-vocabulary.md's decision table for the full "which one
do I need" guide across all three (PrefixedKeyCodec/DottedKeyCodec/
DottedPatchMapCodec).
Rejecting a no-op patch: codex.IsEmptyPatch(patchCodec, patch) bool
reports whether a PartialStruct-built patch encodes to an empty object
(every field unset) — generalizing the "reject a no-op patch" guard
several packages hand-roll per patch type. codex.NonEmptyPatch(patchCodec)
wraps it as a Constraint[P]; codex.EmptyPatchError{} (implements
slog.LogValuer) is the generic error for callers who don't need extra
per-patch context.
Governed pipeline use: forge.Patch wraps ApplyPatch as a named,
versioned, contract-hashed pipeline step — see
Pipelines.
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}}.
Ergonomic constructors, methods, and free functions¶
Constructing an Either used to require a manual struct literal
(Either[A,B]{Left: &a}) and consuming one required a manual nil-check or
type switch (as shown in the switch { case e.Left != nil: ... } pattern
in Either's own doc comment — still the correct escape hatch, but no
longer the ONLY option). Left/Right construct directly; IsLeft/
IsRight/Swap are methods (legal since they don't introduce a new type
parameter beyond the receiver's own A/B); EitherFold/EitherMapLeft/
EitherMapRight are free functions (each introduces a NEW type parameter
— R, C, C respectively — which Go generic methods cannot do, the
same constraint Maybe[T]'s MaybeMap/MaybeFlatMap already document):
e := codex.Left[string, int]("hello") // Either[string, int]
e.IsLeft() // true
e.Swap() // Either[int, string]{Right: &"hello"}
msg := codex.EitherFold(e,
func(s string) string { return "text: " + s },
func(n int) string { return "number" },
) // "text: hello"
lengths := codex.EitherMapLeft(e, func(s string) int { return len(s) })
// Either[int, int]{Left: &5} -- a Right value would pass through untouched
Edge case: a zero-value Either[A,B]{} (no constructor used, no
successful decode) reports false for BOTH IsLeft() and IsRight() —
already an implicitly invalid state per Either's own "Left and Right are
mutually exclusive" contract; always construct via Left/Right/a
successful Either2 decode. EitherMapLeft/EitherMapRight degrade
GRACEFULLY on that same zero-value input (both branches nil in, both
branches nil out — no panic, since they never need to produce a bare R
without calling fn). EitherFold, by contrast, PANICS (nil-pointer
dereference) on a zero-value Either — it MUST call onLeft or onRight
to produce its R result, and has no branch left to fall back to once
both e.Left/e.Right are nil.
No new structured error types and no observer integration are needed:
these are pure in-memory combinators with no I/O — any failure mode
belongs to the caller's own fn/onLeft/onRight closures, not to
codex itself.
EitherField — the Struct-field convenience for Either¶
Either2(ca, cb) derives a plain Codec[Either[A,B]] — usable directly
with RequiredField/OptionalField/DefaultField today, but every call
site has to spell out Either2(ca, cb) itself. EitherField is a
one-line convenience matching MaybeField's own call-site shape (name +
codec(s) + get/set → FieldCodec[T]), literal sugar for
RequiredField(name, Either2(ca, cb), get, set):
var serviceCodec = codex.Struct[Service](
codex.EitherField("port", codex.String(), codex.Int(),
func(s Service) codex.Either[string, int] { return s.Port },
func(s *Service, v codex.Either[string, int]) { s.Port = v }),
)
Always Required, unlike MaybeField — a valid Either always holds
EXACTLY one of Left/Right (see Either's own "mutually exclusive"
contract), so there is no natural "absent" state for EitherField to make
optional the way Maybe's Nothing is. For an OPTIONAL Either field
(may be absent, OR Left, OR Right), compose MaybeField with
Either2 directly instead: MaybeField(name, Either2(ca, cb), get, set)
— a Maybe[Either[A,B]] field.
How does EitherField render in Schema? As Either2's own {oneOf:
[...]} shape, AND present in the Struct's Required array — verified by
an actual render:
codex.EitherField("value", codex.String(), codex.Int(), get, set)
// Schema -> {"OneOf": [{"Type": "string"}, {"Type": "integer"}]}
// AND "value" appears in the Struct's "Required" list.
Unlike MaybeField (above), EitherField's schema genuinely DOES differ
visibly from what a plain scalar field's schema would look like — the
oneOf two-branch shape is real, wire-level information a schema
consumer (OpenAPI/AsyncAPI/JSON Schema tooling) needs to validate a
document correctly, not a Go-side-only distinction the way Maybe[T]'s
presence-tracking is.
This completes the full 2×2 grid of Maybe/Either field-declaration
tools:
| Plain codec constructor | Struct-field convenience | |
|---|---|---|
Maybe |
MaybeCodec[T](inner) |
MaybeField(name, codec, get, set) |
Either |
Either2(ca, cb) |
EitherField(name, ca, cb, get, set) |
Both rows compose freely with every OTHER field constructor too
(OptionalField/DefaultField/OmitEmptyFieldFunc) via their LEFT
column's plain codec — the right column is purely a named shortcut for
the single most common shape, not a separate mechanism.
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.
PrefixedKeyCodec — a prefix + validated-name-segment convenience¶
A recurring shape built on MapCodecValidated (see "Recommended key codec" above): a wire key is a fixed PREFIX followed by a validated NAME segment — "properties.desired.modules.cv-writer", "user:42", "tenant-acme.cv-writer-kvrocks". PrefixedKeyCodec[B ~string] generalizes the two-layer recipe (full-key shape via an internal Constraint, name segment via the caller's own Constraint) into one constructor:
type UserKey string
var UserKeyCodec = codex.PrefixedKeyCodec[UserKey](
"user:", validate.Slug,
)
key, _ := UserKeyCodec.Encode(UserKey("42"))
// key == "user:42"
name, _ := UserKeyCodec.Decode("user:42")
// name == UserKey("42")
B can be a bare string (e.g. examples/flat-key-patch's containerKeyCodec) or a named string type. Reach for PrefixedKeyCodec whenever the key shape is EXACTLY "prefix + name" and you want the MINIMAL constructor for it — no FieldCodec/template ceremony. DottedKeyCodec (above) covers this SAME single-segment case too (see examples/go-edge-models's manifesttemplate.ModuleNameCodec/RouteNameCodec, both built on DottedKeyCodec over a one-{var} template) via the more general MQTT-style template mechanism — reach for DottedKeyCodec instead when a package already uses it for OTHER multi-segment/wildcard keys and you want one consistent vocabulary, or when the key shape might grow beyond one segment later. A key with a different internal structure (e.g. two segments joined by a delimiter, like <tenant>.<name>) needs DottedKeyCodec's multi-{var} template (see "MQTT-style dotted-key templates" above) — examples/flat-key-patch's twoPartKeyCodec is built on it.
See Guide: Wire-format vocabulary for how this fits into a package's single-source-of-truth keys.go.
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:
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.Unmarshal → map[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.Encode → map[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 |
"<b>Hello</b>" |
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.ReadFile → json.Unmarshal → map[string]any → eventCodec.Decode → for the payload field: EmbeddedJSON parses the string value → userCodec.Decode → User. 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¶
- validate package — built-in constraints
- format package — JSON, YAML, TOML, Gob
- Feature: Error Handling
- Examples: construction
- Examples: codec-mapping
- Examples: formats