Zog is a Zod-shaped API over Go semantics, and the two disagree in ways that still compile. Import as
z "github.com/Oudwins/zog". Three biases decide most calls:
- A remembered Zod default is a defect until checked. Fields are optional by default,
z.Enum does not exist, and a
schema key names a Go struct field rather than an input key.
Parse and Validate disagree about what missing means, and that disagreement decides which tests in a chain run
at all.
- Zog panics on a schema its author built wrong, and never on input data. A panic is a defect in the schema
definition, fixed there.
Schema Shape
- A
z.Shape key names a Go struct field, never an input key. The first letter is case-corrected, so "name" and
"Name" both bind to Name. A key matching no field panics with
Struct Schema Definition Error ... missing expected schema key.
- Struct tags map input keys and nothing else. Resolution runs
json, form, query, or env for the source in
use, then zog, then the schema key as written.
z.Struct(...).Required() and .Optional() compile and do nothing. An optional nested struct needs
z.Ptr(z.Struct(...)).NotNil().
- Wrap the schema in
z.Ptr wherever the destination field is a pointer. A z.Slice(...) against a *[]T field
panics with a type-cast error.
z.Ptr carries only .NotNil(). Put Required, Default, Catch, Test, and Transform on the inner schema.
- Declare every schema once, at package level. A schema rebuilt per call costs roughly twice the time and several
times the memory of a reused one, and the gap widens with the number of fields.
Pick, Omit, Extend, and Merge return shallow copies and are not type-checked. A key naming no struct field
panics at execution rather than failing to compile.
Read [${CLAUDE_SKILL_DIR}/references/schema-catalog.md] when reaching for a constructor, a validator, a test option,
or an issue code that is not already in the chain — it carries every schema type with its full method set and
signatures, the three option families, and the issue-code and type constants.
Missing Values
Parse and Validate implement "missing" differently, and every rule below follows from that split.
Parse treats only a nil input as missing. z.String().Required().Parse("", &dest) reports nothing, and an empty
form field, an empty query parameter, and a JSON "" all arrive present. Add .Min(1) where an empty value must
fail.
Validate treats every Go zero value as missing, so a legitimate 0, "", or false fails .Required().
- A missing value skips every test on an optional schema. Under
Validate that means z.Bool().True() accepts
false, z.Int().GT(10) accepts 0, z.Slice(...).Min(1) accepts an empty slice, and a custom TestFunc never
runs. Wrapping in z.Ptr(...).NotNil() does not change it, because the inner schema still short-circuits.
- To reject a zero value under
Validate, use .Required() and set its message —
z.Bool().Required(z.Message("terms must be accepted")). No other test reaches the value.
.Required() runs before every test and transform, whatever its position in the chain.
z.String().Trim().Required() accepts " " and then trims it to "".
.Default outranks .Required and applies in both modes, so Validate replaces an explicitly set zero value
with the default. Tests still run on the substituted value.
.Catch swallows every issue — coercion failure, required, and each test — writes its value, and stops the chain.
Keep it off anything whose failure must reach the caller.
- Structs support neither
.Default nor .Catch; slices support .Default but not .Catch.
Parse and Validate
Parse(data, &dest, opts...) at an IO boundary, Validate(&value, opts...) on an already-typed value. Parse
coerces the input; Validate does not, and allocates nothing on the success path.
- Both destinations are pointers. A value destination panics rather than reporting an issue.
z.WithCoercer is a SchemaOption and belongs on the constructor — z.String(z.WithCoercer(fn)).
z.WithCtxValue and z.WithIssueFormatter are ExecOptions and belong on Parse or Validate.
- Read a per-execution value inside a test with
ctx.Get(key), paired with z.WithCtxValue(key, val) at the call.
Tests and Transforms
- Chain position is execution order.
z.String().Trim().Min(3) measures the trimmed value; .Min(3).Trim()
measures the raw one.
.TestFunc(fn, opts...) is Zod's refine; .Test(z.Test[T]{Func: ...}) is superRefine, and the latter adds
its own issues with ctx.AddIssue(ctx.Issue().SetMessage(...)).
- A primitive test receives a typed pointer; a struct, slice, or map test receives
any — assert it to the struct
pointer inside the function.
- The package-level
z.TestFunc(code, fn, opts...) builds a reusable z.Test[T] for .Test(...). It takes an
issue code as its first argument; the method of the same name does not.
.Not() negates only the next test and prefixes that test's issue code with not_. The interface it returns
omits Min, Max, Test, TestFunc, and a second Not.
- A transform mutates through its pointer and returns an error to stop the chain.
.Trim() is a transform, so it
runs where it sits rather than before the tests.
z.Preprocess(fn, schema) receives the raw input under Parse but a pointer to the value under Validate. A
type mismatch there panics rather than reporting an issue.
Types Without a Built-In Schema
- A named type over a primitive needs the
Like constructor — z.StringLike[Env](), z.IntLike[Status](). A plain
z.String() against a type Env string field panics with a type-cast error.
- A type Zog does not know takes
z.CustomFunc(fn, opts...), which validates through a typed pointer and performs
no coercion.
- A wrapper such as
sql.NullString takes z.Boxed(schema, unbox, box), added in v0.21.10.
Read [${CLAUDE_SKILL_DIR}/references/custom-schemas.md] when a value's Go type has no built-in schema, or its input
shape does not match one — it carries the named-type, CustomFunc, Boxed, and fully custom z.Use forms with working
code, the Preprocess contract, the coercion hooks, and the experimental map and recursive constructors.
Issues
- Every schema returns
z.ZogIssueList, which is []*z.ZogIssue; test it with len(errs) > 0.
- An issue path segment is the input key, not the schema key. One schema yields
first_name through zjson and
first-name through Validate, because each source resolves the field through its own tag. Give a field a zog tag
equal to its json tag where the error keys must stay stable across both.
- A root issue carries a nil path and flattens under
zconst.ISSUE_KEY_ROOT, the string "$root".
issue.Message is the only field safe to return to a user. issue.Err holds the underlying cause and belongs in
a log.
- Never read an issue after
z.Issues.Collect — it returns the struct to a pool the next parse draws from.
Read [${CLAUDE_SKILL_DIR}/references/issue-handling.md] when a validation failure has to be shaped into a response
body, a template, or a log line — it carries the ZogIssue fields, the path rules per input source, and the rendered
output of Flatten, GroupByFlattenedPath, Treeify, and Prettify.
Input Adapters
zhttp.Request(r) routes on method first, then on Content-Type, and falls through to query parsing for any
unrecognized content type — a JSON body sent as text/plain silently yields nothing.
- Multipart requires
r.ParseMultipartForm in the handler first. Without it the parser reports
invalid_multipart_form and the schema never runs.
zhttp and zjson parse into a struct only. A JSON array, a bare primitive, and null each produce one
invalid_json root issue.
- An adapter failure produces one root issue and stops —
invalid_json, invalid_form, invalid_multipart_form,
or invalid_query.
zenv trims every value and treats an empty variable as absent, so PORT= triggers .Required() and takes a
.Default().
Read [${CLAUDE_SKILL_DIR}/references/input-adapters.md] when the input comes from an HTTP request, a JSON body, or the
environment — it carries the handler and config shapes, the content-type routing table, the query and form value rules
including repeated and []-suffixed keys, and the parser override hook.
Messages
- Set a message on the test that produces it —
z.Message("..."), or z.MessageFunc for one built from e.Params
and e.Value.
- Reassigning
conf.DefaultIssueMessageMap or conf.DefaultErrMsgMap silently does nothing. The formatter closes
over the map at package initialization. Mutate entries in place, or install a new formatter with
conf.IssueFormatter = conf.NewDefaultFormatter(m).
Read [${CLAUDE_SKILL_DIR}/references/messages-and-i18n.md] when an issue message must be overridden or served in more
than one language — it carries the four override layers with their precedence, the message template placeholders, and
the i18n setup with its language-key and fallback rules.
Panics
A panic always names a schema definition error, never bad input. Four causes account for every one:
- a
z.Shape key naming no struct field
- a destination passed by value rather than by pointer
- a schema whose type does not match the destination field
- a coercer returning a value of the wrong Go type
Fix the schema. Never wrap a Parse or Validate call in recover.
Application
When writing Zog, apply these conventions silently — do not narrate a rule while following it. Declare schemas as
package-level variables and check every result with len(errs) > 0. Where existing code contradicts a convention,
follow the codebase and flag the divergence once.
When reviewing Zog, cite the violation and show the fix inline. Do not lecture. Treat a .Required() on a value
arriving from a form, a query string, or JSON as a defect until an emptiness test sits beside it.
Bad: "Required only fires on a nil input, so an empty query parameter passes through..."
Good: z.String().Required() -> z.String().Required().Min(1)
Integration
The golang skill governs every Go decision outside the Zog API — naming, error handling, testing conventions, and
the toolchain — and wins on any question of how the Go code reads. This skill governs schema definition and the Zog
runtime API, and its key-naming rule outranks Go naming inside a z.Shape. The coding skill governs workflow. All
are active at once.
Zog is pre-1.0 and breaks its API across minor versions. Read the version in go.mod before writing against anything
this skill anchors to one.
A schema states what the data must be; the mode decides what missing means.
1---2name: zog3description: Write and review Zog schema validation in Go: schema shape, Parse against Validate, required and default semantics, tests and transforms, issue handling, and the zhttp, zjson, and zenv adapters.4---56Zog is a Zod-shaped API over Go semantics, and the two disagree in ways that still compile. Import as7`z "github.com/Oudwins/zog"`. Three biases decide most calls:89- **A remembered Zod default is a defect until checked.** Fields are optional by default, `z.Enum` does not exist, and a10 schema key names a Go struct field rather than an input key.11- **`Parse` and `Validate` disagree about what missing means**, and that disagreement decides which tests in a chain run12 at all.13- **Zog panics on a schema its author built wrong, and never on input data.** A panic is a defect in the schema14 definition, fixed there.1516## Schema Shape1718- **A `z.Shape` key names a Go struct field, never an input key.** The first letter is case-corrected, so `"name"` and19 `"Name"` both bind to `Name`. A key matching no field panics with20 `Struct Schema Definition Error ... missing expected schema key`.21- **Struct tags map input keys and nothing else.** Resolution runs `json`, `form`, `query`, or `env` for the source in22 use, then `zog`, then the schema key as written.23- **`z.Struct(...).Required()` and `.Optional()` compile and do nothing.** An optional nested struct needs24 `z.Ptr(z.Struct(...)).NotNil()`.25- **Wrap the schema in `z.Ptr` wherever the destination field is a pointer.** A `z.Slice(...)` against a `*[]T` field26 panics with a type-cast error.27- **`z.Ptr` carries only `.NotNil()`.** Put `Required`, `Default`, `Catch`, `Test`, and `Transform` on the inner schema.28- **Declare every schema once, at package level.** A schema rebuilt per call costs roughly twice the time and several29 times the memory of a reused one, and the gap widens with the number of fields.30- **`Pick`, `Omit`, `Extend`, and `Merge` return shallow copies and are not type-checked.** A key naming no struct field31 panics at execution rather than failing to compile.3233Read [`${CLAUDE_SKILL_DIR}/references/schema-catalog.md`] when reaching for a constructor, a validator, a test option,34or an issue code that is not already in the chain — it carries every schema type with its full method set and35signatures, the three option families, and the issue-code and type constants.3637## Missing Values3839`Parse` and `Validate` implement "missing" differently, and every rule below follows from that split.4041- **`Parse` treats only a nil input as missing.** `z.String().Required().Parse("", &dest)` reports nothing, and an empty42 form field, an empty query parameter, and a JSON `""` all arrive present. Add `.Min(1)` where an empty value must43 fail.44- **`Validate` treats every Go zero value as missing**, so a legitimate `0`, `""`, or `false` fails `.Required()`.45- **A missing value skips every test on an optional schema.** Under `Validate` that means `z.Bool().True()` accepts46 `false`, `z.Int().GT(10)` accepts `0`, `z.Slice(...).Min(1)` accepts an empty slice, and a custom `TestFunc` never47 runs. Wrapping in `z.Ptr(...).NotNil()` does not change it, because the inner schema still short-circuits.48- **To reject a zero value under `Validate`, use `.Required()` and set its message** —49 `z.Bool().Required(z.Message("terms must be accepted"))`. No other test reaches the value.50- **`.Required()` runs before every test and transform, whatever its position in the chain.**51 `z.String().Trim().Required()` accepts `" "` and then trims it to `""`.52- **`.Default` outranks `.Required` and applies in both modes**, so `Validate` replaces an explicitly set zero value53 with the default. Tests still run on the substituted value.54- **`.Catch` swallows every issue** — coercion failure, required, and each test — writes its value, and stops the chain.55 Keep it off anything whose failure must reach the caller.56- **Structs support neither `.Default` nor `.Catch`; slices support `.Default` but not `.Catch`.**5758## Parse and Validate5960- **`Parse(data, &dest, opts...)` at an IO boundary, `Validate(&value, opts...)` on an already-typed value.** Parse61 coerces the input; Validate does not, and allocates nothing on the success path.62- **Both destinations are pointers.** A value destination panics rather than reporting an issue.63- **`z.WithCoercer` is a `SchemaOption` and belongs on the constructor** — `z.String(z.WithCoercer(fn))`.64 `z.WithCtxValue` and `z.WithIssueFormatter` are `ExecOption`s and belong on `Parse` or `Validate`.65- **Read a per-execution value inside a test with `ctx.Get(key)`**, paired with `z.WithCtxValue(key, val)` at the call.6667## Tests and Transforms6869- **Chain position is execution order.** `z.String().Trim().Min(3)` measures the trimmed value; `.Min(3).Trim()`70 measures the raw one.71- **`.TestFunc(fn, opts...)` is Zod's `refine`; `.Test(z.Test[T]{Func: ...})` is `superRefine`**, and the latter adds72 its own issues with `ctx.AddIssue(ctx.Issue().SetMessage(...))`.73- **A primitive test receives a typed pointer; a struct, slice, or map test receives `any`** — assert it to the struct74 pointer inside the function.75- **The package-level `z.TestFunc(code, fn, opts...)` builds a reusable `z.Test[T]` for `.Test(...)`.** It takes an76 issue code as its first argument; the method of the same name does not.77- **`.Not()` negates only the next test** and prefixes that test's issue code with `not_`. The interface it returns78 omits `Min`, `Max`, `Test`, `TestFunc`, and a second `Not`.79- **A transform mutates through its pointer and returns an error to stop the chain.** `.Trim()` is a transform, so it80 runs where it sits rather than before the tests.81- **`z.Preprocess(fn, schema)` receives the raw input under `Parse` but a pointer to the value under `Validate`.** A82 type mismatch there panics rather than reporting an issue.8384## Types Without a Built-In Schema8586- **A named type over a primitive needs the `Like` constructor** — `z.StringLike[Env]()`, `z.IntLike[Status]()`. A plain87 `z.String()` against a `type Env string` field panics with a type-cast error.88- **A type Zog does not know takes `z.CustomFunc(fn, opts...)`**, which validates through a typed pointer and performs89 no coercion.90- **A wrapper such as `sql.NullString` takes `z.Boxed(schema, unbox, box)`**, added in v0.21.10.9192Read [`${CLAUDE_SKILL_DIR}/references/custom-schemas.md`] when a value's Go type has no built-in schema, or its input93shape does not match one — it carries the named-type, `CustomFunc`, `Boxed`, and fully custom `z.Use` forms with working94code, the `Preprocess` contract, the coercion hooks, and the experimental map and recursive constructors.9596## Issues9798- **Every schema returns `z.ZogIssueList`, which is `[]*z.ZogIssue`; test it with `len(errs) > 0`.**99- **An issue path segment is the input key, not the schema key.** One schema yields `first_name` through `zjson` and100 `first-name` through `Validate`, because each source resolves the field through its own tag. Give a field a `zog` tag101 equal to its `json` tag where the error keys must stay stable across both.102- **A root issue carries a nil path** and flattens under `zconst.ISSUE_KEY_ROOT`, the string `"$root"`.103- **`issue.Message` is the only field safe to return to a user.** `issue.Err` holds the underlying cause and belongs in104 a log.105- **Never read an issue after `z.Issues.Collect`** — it returns the struct to a pool the next parse draws from.106107Read [`${CLAUDE_SKILL_DIR}/references/issue-handling.md`] when a validation failure has to be shaped into a response108body, a template, or a log line — it carries the `ZogIssue` fields, the path rules per input source, and the rendered109output of `Flatten`, `GroupByFlattenedPath`, `Treeify`, and `Prettify`.110111## Input Adapters112113- **`zhttp.Request(r)` routes on method first, then on Content-Type**, and **falls through to query parsing for any114 unrecognized content type** — a JSON body sent as `text/plain` silently yields nothing.115- **Multipart requires `r.ParseMultipartForm` in the handler first.** Without it the parser reports116 `invalid_multipart_form` and the schema never runs.117- **`zhttp` and `zjson` parse into a struct only.** A JSON array, a bare primitive, and `null` each produce one118 `invalid_json` root issue.119- **An adapter failure produces one root issue and stops** — `invalid_json`, `invalid_form`, `invalid_multipart_form`,120 or `invalid_query`.121- **`zenv` trims every value and treats an empty variable as absent**, so `PORT=` triggers `.Required()` and takes a122 `.Default()`.123124Read [`${CLAUDE_SKILL_DIR}/references/input-adapters.md`] when the input comes from an HTTP request, a JSON body, or the125environment — it carries the handler and config shapes, the content-type routing table, the query and form value rules126including repeated and `[]`-suffixed keys, and the parser override hook.127128## Messages129130- **Set a message on the test that produces it** — `z.Message("...")`, or `z.MessageFunc` for one built from `e.Params`131 and `e.Value`.132- **Reassigning `conf.DefaultIssueMessageMap` or `conf.DefaultErrMsgMap` silently does nothing.** The formatter closes133 over the map at package initialization. Mutate entries in place, or install a new formatter with134 `conf.IssueFormatter = conf.NewDefaultFormatter(m)`.135136Read [`${CLAUDE_SKILL_DIR}/references/messages-and-i18n.md`] when an issue message must be overridden or served in more137than one language — it carries the four override layers with their precedence, the message template placeholders, and138the `i18n` setup with its language-key and fallback rules.139140## Panics141142A panic always names a schema definition error, never bad input. Four causes account for every one:143144- a `z.Shape` key naming no struct field145- a destination passed by value rather than by pointer146- a schema whose type does not match the destination field147- a coercer returning a value of the wrong Go type148149Fix the schema. Never wrap a `Parse` or `Validate` call in `recover`.150151## Application152153When **writing** Zog, apply these conventions silently — do not narrate a rule while following it. Declare schemas as154package-level variables and check every result with `len(errs) > 0`. Where existing code contradicts a convention,155follow the codebase and flag the divergence once.156157When **reviewing** Zog, cite the violation and show the fix inline. Do not lecture. Treat a `.Required()` on a value158arriving from a form, a query string, or JSON as a defect until an emptiness test sits beside it.159160```161Bad: "Required only fires on a nil input, so an empty query parameter passes through..."162Good: z.String().Required() -> z.String().Required().Min(1)163```164165## Integration166167The **golang** skill governs every Go decision outside the Zog API — naming, error handling, testing conventions, and168the toolchain — and wins on any question of how the Go code reads. This skill governs schema definition and the Zog169runtime API, and its key-naming rule outranks Go naming inside a `z.Shape`. The **coding** skill governs workflow. All170are active at once.171172Zog is pre-1.0 and breaks its API across minor versions. Read the version in `go.mod` before writing against anything173this skill anchors to one.174175**A schema states what the data must be; the mode decides what missing means.**