google / google/jsonschema-go

Proposal: auto-detect json.Marshaler primitive types, or add a JSONSchema() interface hook

Open
#77 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
478
Forks
37
PR merge metrics
No merged PRs in 30d

Description

## Problem

Library types whose `MarshalJSON` emits a JSON primitive (string / number / bool) currently render as `{type: "object"}` when reflected through `jsonschema.For[T]`, because the generator inspects the Go struct definition rather than the marshaled form.

A concrete example: `github.com/luno/luno-go/decimal.Decimal` is a struct with private fields (`i *big.Int`, `scale int`) and a `MarshalJSON` that emits a string like `"100.50"`. Generated schema:

```json
"price": {
"type": "object",
"properties": {},
"additionalProperties": false
}
```

Runtime value:

```json
"price": "100.50"
```

This is unspecific to crypto / decimals - any third-party "smart wrapper" type (URLs, UUIDs, dates, IPs, ULIDs, custom numerics, money types) hits the same shape. Today the only escape hatch is `ForOptions.TypeSchemas`, which puts the burden on every consumer to know which third-party types need overriding.

A real-world example where this broke production: in [luno/luno-mcp](https://github.com/luno/luno-mcp), an MCP server that uses `mcp-go`'s `WithOutputSchema[T]` (which wraps `jsonschema.For[T]`). Strict MCP clients validate tool output against the declared schema and reject every order book entry with `data/asks/0/price must be object`. Our server-side code looked correct, the wire payload looked correct, but the generated schema disagreed - silently, because the host server didn't enable validation locally. We had to manually enumerate the affected wrapper types and register them via `TypeSchemas`.

## Existing precedent

`initialSchemaMap` already does exactly this for `time.Time`, `slog.Level`, and `big.Int`. The library knows the pattern; it just can't generalise to types it doesn't ship.

## Proposed solutions

Two non-exclusive options:

### (a) Auto-detect `json.Marshaler` with a probe

For any unrecognised type `T` that implements `json.Marshaler`:
1. Construct a zero value (or, if `MarshalJSON` panics on zero, fall back to existing behaviour).
2. Call `MarshalJSON` once.
3. Inspect the first non-whitespace byte: `\"` → string, `t`/`f` → boolean, `0-9`/`-` → number, `[` → array, `{` → object, `n` → null.
4. For primitive results, emit the matching schema; for `{`/`[`/`null` fall back to today's reflection.

Pros: zero API surface change, fixes the long tail automatically.
Cons: probing at schema-generation time has surprising failure modes (panics, side effects, types that need real data to marshal). Could be opt-in via `ForOptions.DetectMarshalerPrimitives bool`.

### (b) A `JSONSchema()` interface hook

```go
type Schemer interface {
JSONSchema() *jsonschema.Schema
}
```

If `T` (or `*T`) implements `Schemer`, `forType` uses its returned schema verbatim. Type authors opt in once; consumers get correct schemas for free.

Pros: explicit, no probing, composable with `TypeSchemas` (which still wins).
Cons: requires type authors to add a method - though the same is true for `MarshalJSON`/`String`/etc.

## Related

- #37 (Global Type Schema Overrides) — solves the same downstream pain (DRY across `For` calls), but still puts the burden on consumers to know which types need overrides.
- #41 (`*time.Time` not translated to `["null","string"]`) — same root cause: special-cased stdlib type doesn't generalise.

Happy to put up a PR for either option (or both) once there's directional buy-in - leaning towards (b) for predictability.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.