# go-gen-jsonschema `go-gen-jsonschema` generates deterministic JSON Schema from Go types. It is designed for LLM tool definitions and structured output: properties follow Go field order, unknown properties are rejected, and Go doc comments become schema descriptions. The generator also writes Go accessors and can generate runtime JSON validation. Repository: https://github.com/tylergannon/go-gen-jsonschema Human documentation: https://go-gen-jsonschema.tylergannon.com ## Recommended setup Use Go's tool directive so the generator version is pinned in `go.mod` and is the same for developers and CI: ```bash go get -tool github.com/tylergannon/go-gen-jsonschema/gen-jsonschema@latest ``` Define the types and generation command: ```go // types.go package contacts import jsonschema "github.com/tylergannon/go-gen-jsonschema" //go:generate go tool gen-jsonschema --validate // Person is a contact extracted from a document. type Person struct { // Full legal name. Name string `json:"name"` // Email address. Omit when the source does not provide one. Email jsonschema.Optional[string] `json:"email,omitzero"` // Required key; null means no phone number was supplied. Phone jsonschema.Nullable[string] `json:"phone"` } ``` Scaffold the build-tagged registration file and generate: ```bash go tool gen-jsonschema new \ -out schema.go \ -methods 'Person=Schema' \ --validate \ --generate go mod tidy ``` `go mod tidy` is needed with `--validate` because generated code imports `github.com/santhosh-tekuri/jsonschema/v6`. The scaffolded file has the generation-only build tag and panic stubs: ```go // schema.go //go:build jsonschema package contacts import ( "encoding/json" jsonschema "github.com/tylergannon/go-gen-jsonschema" ) func (Person) Schema() json.RawMessage { panic("not implemented") } func (Person) ValidateJSON(_ []byte) error { panic("not implemented") } var _ = jsonschema.NewJSONSchemaMethod(Person.Schema) ``` Generation writes: - `jsonschema/Person.json`: the schema; - `jsonschema/Person.json.sum`: its change-detection checksum; and - `jsonschema_gen.go`: production implementations of `Schema()` and, when requested, `ValidateJSON()`. Commit `schema.go`, `jsonschema_gen.go`, and the complete `jsonschema/` directory. Use `Person{}.Schema()` to obtain the embedded schema as `json.RawMessage` for a tool definition or structured-output request. ## Build-tag model The registration and generated files are mutually exclusive: | File | Build tag | Purpose | | --- | --- | --- | | `schema.go` | `//go:build jsonschema` | User-written stubs and registrations, compiled only during generation. | | `jsonschema_gen.go` | `//go:build !jsonschema` | Generated production accessors, validation, and interface decoding. | The package therefore has one definition of each generated method in either build mode. ## Generated schema contract - Properties are emitted in Go struct field order. - `additionalProperties` is `false` for generated objects. - Type and field doc comments become `description` values. - Nested structs are inlined by default, unless registered with `AsRef()` (see "Shared definitions" below). - Ordinary fields and `Nullable[T]` fields are required. - Only `Optional[T]` removes a property from `required`. For the `Person` example, `required` is `['name', 'phone']`; `email` remains a property but is not required. ## Required, optional, and nullable properties | Go field type | JSON property contract | | --- | --- | | `T` | Required and non-null. | | `jsonschema.Optional[T]` | May be absent; rejects JSON null. | | `jsonschema.Nullable[T]` | Required; accepts JSON null. | `Optional[T]` fields must use `json:",omitzero"`. Their zero value represents absence. A present zero, empty string, or empty slice is preserved through the wrapper's `Present` and `Value` fields. `Nullable[T]` uses `Present == false` for JSON null. Plain `json.Unmarshal` cannot distinguish a missing nullable property from an explicit null, so call generated `ValidateJSON` before unmarshaling whenever required-key presence matters. Do not use `omitempty` to express schema optionality. It affects Go marshaling, not schema requiredness, and can remove a property that the schema requires. The legacy `jsonschema:"optional"` tag is no longer honored; migrate the field to `jsonschema.Optional[T]` with `json:",omitzero"`. Supported wrapper shapes: - `Optional`: scalars and named scalars, structs, pointers, arrays and slices, supported explicit references, and registered interface fields. - `Nullable`: scalars, registered enums, structs, pointers to structs, and structs registered with `AsRef()`. Wrappers must be the complete type of a direct named struct field. Aliases, defined wrapper types, embedding, nesting, wrappers inside containers, and unsupported Nullable shapes fail generation with an error. OpenAI strict Structured Outputs requires every property to be required. Use `Nullable[T]` for a required-plus-null contract. `Optional[T]` is intentionally not compatible with that strict requirement. ## Validate before unmarshaling Pass `--validate` both to the generation directive and to `new`. Generated schemas are compiled once during package initialization. ```go if err := (Person{}).ValidateJSON(llmOutput); err != nil { // Schema-validation failures can be inspected as // *jsonschemav6.ValidationError from // github.com/santhosh-tekuri/jsonschema/v6. Malformed JSON may instead // return a parsing error. return err } var person Person if err := json.Unmarshal(llmOutput, &person); err != nil { return err } ``` Validation checks required fields, value types, unknown properties, enum membership, discriminated union structure, and nested objects. Validation must come first when schema validity and Go decoding have different information, such as missing versus null Nullable fields. ## Schema descriptions Type and field doc comments are copied into the JSON Schema. Write them as instructions for the model producing JSON: define semantics, formats, units, ranges, and when an Optional field should be omitted. ```go // Time the event occurred, formatted as RFC3339, for example // "2026-07-09T14:00:00Z". Timestamp string `json:"timestamp"` ``` Use a `description:"..."` struct tag to override the doc comment in the generated schema. ## Struct tags | Tag | Effect | | --- | --- | | `json:"name"` | Sets the property name using standard Go JSON-tag rules. | | `json:",omitzero"` | Required on `Optional[T]` so an absent wrapper is omitted. | | `description:"..."` | Overrides the field doc comment in the schema. | | `jsonschema:"ref=definitions/T"` | Emits an explicit `$ref`; the referenced schema must be defined by the consumer. | ## Enums For a named string field, `WithEnum` discovers typed constants declared in the same package: ```go type Status string const ( StatusPending Status = "pending" StatusDone Status = "done" ) type Task struct { Status Status `json:"status"` } var _ = jsonschema.NewJSONSchemaMethod( Task.Schema, jsonschema.WithEnum(Task{}.Status), ) ``` For an integer or iota enum, choose the wire representation explicitly: - `WithEnum` emits the raw numeric constant values. - `WithStringerEnum` emits constant names such as `LogDebug` and `LogInfo` as strings. It does not emit the return values of `String()`. The package-level `NewEnumType[T]()` form remains supported, but field-level options are preferred for new code. ## Discriminated interfaces and slices A registered interface field becomes an `anyOf` union. A direct, one-dimensional `[]I` field becomes an array with that union under `items.anyOf`. The generator writes `UnmarshalJSON` dispatch code for scalar interface fields and every slice element. ```go type Event interface{ isEvent() } type Created struct { Name string `json:"name"` } func (Created) isEvent() {} type Deleted struct { ID string `json:"id"` } func (*Deleted) isEvent() {} type Batch struct { Events []Event `json:"events"` } ``` Preferred registration keeps every implementation beside its stable wire discriminator: ```go var _ = jsonschema.NewJSONSchemaMethod( Batch.Schema, jsonschema.WithInterface( Batch{}.Events, jsonschema.Discriminator("!kind"), jsonschema.Impl("created", Created{}), jsonschema.Impl("deleted", (*Deleted)(nil)), ), ) ``` `Impl`'s first argument is the exact discriminator used by both the schema `const` and generated unmarshal dispatch. The default discriminator property is `!type`. Generated decoding preserves value versus pointer implementations, reports a failing slice index, and assigns the destination only after the entire value decodes successfully. The split form remains supported for compatibility. Without explicit `Impl` values, discriminators continue to derive from Go type names: ```go jsonschema.WithInterface(Batch{}.Events), jsonschema.WithInterfaceImpls(Batch{}.Events, Created{}, (*Deleted)(nil)), jsonschema.WithDiscriminator(Batch{}.Events, "!kind"), ``` The generator writes an unmarshaler, not a discriminator-aware marshaler. If a decoded interface value must marshal back into schema-valid JSON, each concrete implementation must include the discriminator, commonly with `MarshalJSON`: ```go func (c Created) MarshalJSON() ([]byte, error) { type plain Created return json.Marshal(struct { Kind string `json:"!kind"` plain }{Kind: "created", plain: plain(c)}) } ``` Supported interface containers are scalar `I` fields and direct `[]I` fields. Fixed arrays, nested slices, named slice containers, `Optional[[]I]`, `Nullable[[]I]`, and inline interface declarations are rejected. An `Optional[I]` scalar field is supported; a `Nullable[I]` field is not. Legacy package-level registration remains available but cannot be mixed with per-field interface options in one package: ```go var _ = jsonschema.NewInterfaceImpl[Event](Created{}, (*Deleted)(nil)) ``` ## Shared definitions (`$ref`/`$defs`) via `AsRef` By default a type referenced from multiple places is inlined at every call site. Add the zero-arg `AsRef()` option to that type's own registration to render it once as `"$ref": "#/$defs/TypeName"` wherever another registered schema references it instead: ```go type Shared struct { Name string `json:"name"` } type Container struct { Primary Shared `json:"primary"` Others []Shared `json:"others"` } var _ = jsonschema.NewJSONSchemaMethod(Shared.Schema, jsonschema.AsRef()) var _ = jsonschema.NewJSONSchemaMethod(Container.Schema) ``` `Nullable` can wrap an `AsRef()` struct or registered enum. Both properties remain required; their schemas become `anyOf` unions with JSON null, and the referenced struct remains in `$defs`: ```go type Mode string const ( ModeFast Mode = "fast" ModeSafe Mode = "safe" ) type NullableConfig struct { Mode jsonschema.Nullable[Mode] `json:"mode"` Shared jsonschema.Nullable[Shared] `json:"shared"` } var ( _ = jsonschema.NewJSONSchemaMethod(NullableConfig.Schema) _ = jsonschema.NewEnumType[Mode]() ) ``` `$defs` are assembled per generated JSON file, keyed by the type's bare name. Two distinct `AsRef()`-registered types reachable in one generation run that share a bare name are a hard, generation-time error. Recursive or self-referencing `AsRef()` types are rejected, the same as any other circular reference. ## Provider-rendered schemas Provider options replace selected field schemas at runtime: - `WithFunction(field, fn)` calls a package function; - `WithStructAccessorMethod(field, method)` calls a receiver accessor; - `WithStructFunctionMethod(field, method)` calls a receiver method with the field value; and - `WithRenderProviders()` generates `RenderedSchema()` and enables provider execution. Provider functions must exist in normal builds. Provider-rendered schemas do not receive `ValidateJSON()` because their schema depends on runtime values. ## Manual schema construction For runtime-defined schemas that do not come from a Go type, use the exported schema helpers such as `StringSchema`, `BoolSchema`, `IntSchema`, `ArraySchema`, `EnumSchema`, `ConstSchema`, `RefSchemaEl`, and `UnionSchemaEl`. `JSONSchema.Properties` is map-based and marshals in sorted key order. Use `ObjectSchema` with `AddProperty` and `AddRequiredProperty` when insertion order must be preserved. ## Registration API | Marker or option | Purpose | | --- | --- | | `NewJSONSchemaMethod(T.Schema, ...opts)` | Primary method registration. | | `NewJSONSchemaFunc(fn, ...opts)` | Register a free schema accessor. | | `NewJSONSchemaBuilder[T](fn)` | Register a manually built `SchemaFunction`. | | `NewEnumType[T]()` | Legacy package-level enum registration. | | `NewInterfaceImpl[I](impls...)` | Legacy package-level interface registration. | | `WithEnum(field)` | Render same-package typed string or numeric constant values. | | `WithStringerEnum(field)` | Render integer constant names as strings. | | `WithInterface(field, options...)` | Register an interface field, optionally with cohesive `Discriminator` and `Impl` options. | | `Discriminator(name)` | Set the discriminator property inside `WithInterface`. | | `Impl(value, implementation)` | Bind a stable wire discriminator to an implementation inside `WithInterface`. | | `WithInterfaceImpls(field, impls...)` | List its accepted concrete types. | | `WithDiscriminator(field, name)` | Override the default `!type` property. | | `WithFunction(field, fn)` | Render a field schema with a package function. | | `WithStructAccessorMethod(field, method)` | Render with a receiver accessor. | | `WithStructFunctionMethod(field, method)` | Render with a receiver method that accepts the field value. | | `WithRenderProviders()` | Enable runtime provider rendering. | | `AsRef()` | Render this type as a `$ref` into `$defs` wherever another registered schema references it, instead of inlining it. | Registration markers are read from the build-tagged file by the generator and are no-ops at runtime. ## CLI reference Invoke the pinned tool as `go tool gen-jsonschema`: ```text go tool gen-jsonschema go tool gen-jsonschema gen [flags] -pretty indent schema JSON -target DIR package to process; default is the current directory -no-changes fail without writing schema files if schema JSON would change -force rewrite unchanged output; incompatible with -no-changes -num-test-samples N accepted for compatibility; currently does not change output --validate generate ValidateJSON methods go tool gen-jsonschema new [flags] -out FILE output path; empty or -- writes to stdout -pkg NAME package override for stdout mode -methods LIST required comma-separated Type=Method entries --validate include ValidateJSON stubs --generate run go generate ./... after writing ``` The command without a subcommand is equivalent to `gen`. Any non-empty `JSONSCHEMA_NO_CHANGES` value is equivalent to `-no-changes` and flows through existing `go generate` directives. No-change mode guards schema JSON; generation can still update `jsonschema_gen.go` when schemas are unchanged. ## Keep generated output current Generate normally after changing registered types: ```bash go generate ./... go build ./... go test ./... ``` Combine no-change mode with a Git diff in CI so both schema JSON and generated Go code are checked: ```yaml - name: Check generated schemas run: JSONSCHEMA_NO_CHANGES=1 go generate ./... && test -z "$(git status --porcelain)" - name: Test run: go test ./... ``` If the repository contains generators that do not understand `JSONSCHEMA_NO_CHANGES`, run `go generate ./...` and then require `test -z "$(git status --porcelain)"`. Unlike `git diff --exit-code`, this also detects untracked generated files. ## Known limitations - Maps, channels, functions, and inline interfaces are unsupported. - Circular and recursive references are rejected. - External package types are unsupported except `time.Time`, which is rendered as an RFC3339-guided string. - Maximum nesting depth is 100. - Provider-rendered schemas cannot generate static validation methods. - Interface containers are limited as described above. ## Troubleshooting 1. Confirm `go.mod` contains the tool directive, then invoke it as `go tool gen-jsonschema`. 2. Ensure `schema.go` begins with `//go:build jsonschema` and every registered type exists in the package. 3. Pass `--validate` consistently to both `new` and generation. 4. Run `go mod tidy` after first generating validation code. 5. Check for unsupported wrapper placement, interface containers, maps, or recursive types. 6. Declare enum constants in the same package as their named type. Compiling examples: - Optional and Nullable: `examples/optionality` - Sealed interface slices: `examples/sealed_interface_slices` - Enums: `examples/enums`, `examples/stringer_enums` - Provider rendering: `examples/providers_rendering` - Shared `$ref`/`$defs` via `AsRef`: `examples/ref_types`