Skip to content

infer: Add Schemer interface for self-described types#78

Open
echarrod wants to merge 1 commit into
google:mainfrom
echarrod:proposal/jsonschema-interface-hook
Open

infer: Add Schemer interface for self-described types#78
echarrod wants to merge 1 commit into
google:mainfrom
echarrod:proposal/jsonschema-interface-hook

Conversation

@echarrod

Copy link
Copy Markdown

Implementation sketch for #77.

What

Adds a small interface that lets types self-describe their JSON Schema:

type Schemer interface {
    JSONSchema() *Schema
}

If a type (or *T) implements Schemer, For/ForType use the returned schema instead of inferring one from the Go struct. Returning nil falls back to default inference. ForOptions.TypeSchemas still takes precedence, so callers can override a third-party type they don't control.

Why

Library types whose MarshalJSON emits a JSON primitive (string / number / boolean) currently render as {type: "object"} because the generator inspects the Go struct definition rather than the marshalled form. Examples in the wild: decimal.Decimal from github.com/luno/luno-go/decimal, UUID wrappers, custom time formats, money types.

The library already special-cases time.Time, slog.Level, big.Int, big.Rat, big.Float via initialSchemaMap. Schemer generalises that pattern so type authors can opt in once and downstream consumers get the right schema for free.

The concrete failure that motivated this: an MCP server using mcp-go's WithOutputSchema[T] (which calls jsonschema.For[T]) had every order book entry rejected by strict clients with data/asks/0/price must be object, because decimal.Decimal reflected as object. See luno/luno-mcp#128 for the downstream workaround using ForOptions.TypeSchemas.

Behaviour

  • T implementing Schemer with a value receiver → uses the returned schema.
  • *T implementing Schemer with a pointer receiver → same.
  • JSONSchema() returning nil → falls back to default reflection (no panic).
  • *T field where T implements Schemer → schema is null-wrapped (["null", "string"] etc.), matching how the existing initialSchemaMap lookup behaves for pointers.
  • []T where T implements Schemer → items schema comes from Schemer.
  • ForOptions.TypeSchemas[T] set → wins over Schemer.

Tests

TestForWithSchemer covers value receiver, pointer receiver, pointer-to-Schemer null-wrapping, nil-return fallback, and slice elements.
TestForWithSchemerOverriddenByTypeSchemas covers the precedence rule.

Existing test suite unchanged and green.

Open questions

  1. Should JSONSchema() take a context? I chose the zero-argument signature for symmetry with MarshalJSON and because instance state is meaningless here. Open to passing *ForOptions if there's a need.
  2. Should we cache the result per reflect.Type? Each schema generation currently calls JSONSchema() once per occurrence of a Schemer type in the tree. Cheap, but cacheable if profiling shows a hot path.
  3. Method name. JSONSchema matches MarshalJSON; alternatives are Schema, JSONSchemaFor, OpenAPISchemaType-style. Easy to change.

Happy to iterate.

Types whose MarshalJSON emits a primitive (decimal wrappers, UUIDs,
custom time formats, money types) currently render as {type: "object"}
because the generator reflects on the Go struct definition rather than
the marshalled form. The only escape today is ForOptions.TypeSchemas,
which puts the burden on every consumer.

Add a Schemer interface that lets types self-describe:

    type Schemer interface { JSONSchema() *Schema }

Types implementing it (with either receiver style) get the schema they
declare. Returning nil falls back to default reflection. TypeSchemas
still wins so callers can override third-party types they don't own.

See google#77 for the motivating discussion.
@echarrod

Copy link
Copy Markdown
Author

@jwetzell @rafaeljusto @jba @baptmont would a review be possible? 🙏

@rafaeljusto

Copy link
Copy Markdown
Contributor

Hey @echarrod, I'm not an official repository maintainer, so I'll defer the final review to others.

As you mentioned in the PR description, this use case can already be addressed through the TypeSchemas option. The proposal here seems to be about providing a more convenient mechanism for commonly used custom types, rather than enabling new functionality.

// TypeSchemas maps types to their schemas.
// If [For] encounters a type that is a key in this map, the
// corresponding value is used as the resulting schema (after cloning to
// ensure uniqueness).
// Types in this map override the default translations, as described
// in [For]'s documentation.
// PropertyOrder defined in these schemas will not be used in [For] or [ForType].
TypeSchemas map[reflect.Type]*Schema

Since this doesn't add new capabilities, the question is mostly whether the maintainers want to support this convenience in the library itself (multiple ways of doing the same thing).

@jba

jba commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

I think this is reasonable. It's not really two ways of doing the same thing. TypeSchemas is convenient for the "end-user," the one calling jsonschema.For. But this mechanism lets the type author decide how their type looks as a schema, just as MarshalJSON does the same for JSON marshalling.

I approve. However, I'm no longer the primary maintainer. I'll pass this along.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants