Proposal: auto-detect json.Marshaler primitive types, or add a JSONSchema() interface hook
- 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
Assessment
This issue has not been assessed yet.