Proposal Details
Summary
Custom unmarshalers registered for underlying types (e.g., int
) should also apply to named types based on those underlying types (e.g., type Num int
). Currently, the custom unmarshaler is ignored when the target is a named type.
Go Version
go version go1.25 linux/amd64
What did you do?
I registered a custom unmarshaler for the int
type and expected it to also work when unmarshaling into a named type Num
that has int
as its underlying type.
package main
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
)
type Num int
func main() {
var value Num
opt := json.WithUnmarshalers(json.UnmarshalFromFunc(func(decoder *jsontext.Decoder, t *int) error {
if err := decoder.SkipValue(); err != nil {
return err
}
*t = 2
return nil
}))
if err := json.Unmarshal([]byte(`1`), &value, opt); err != nil {
panic(err)
}
fmt.Println(value) // Expected: 2, Actual: 1
}
What did you expect to see?
Expected output: 2
The custom unmarshaler should be called because Num
has int
as its underlying type.
What did you see instead?
Actual output: 1
The custom unmarshaler is not called, and the default JSON unmarshaling behavior is used.
Why this matters
This limitation forces developers to:
- Enumerate all named types: Register separate unmarshalers for each named type, even when they share the same underlying type and logic.
// Current workaround - very verbose
opt := json.WithUnmarshalers(
json.UnmarshalFromFunc(customIntUnmarshaler[int]),
json.UnmarshalFromFunc(customIntUnmarshaler[Num]),
json.UnmarshalFromFunc(customIntUnmarshaler[UserID]),
json.UnmarshalFromFunc(customIntUnmarshaler[ProductID]),
// ... many more named int types
)
- Implement interfaces for every type: Add
UnmarshalJSONV2
method to each named type, duplicating logic.
func (n *Num) UnmarshalJSONV2(dec *jsontext.Decoder, options json.UnmarshalOptions) error {
return customIntLogic(dec, (*int)(n))
}
func (uid *UserID) UnmarshalJSONV2(dec *jsontext.Decoder, options json.UnmarshalOptions) error {
return customIntLogic(dec, (*int)(uid))
}
// ... repeat for every named type
Proposed Solution
The JSON v2 package should check for custom unmarshalers of the underlying type when no specific unmarshaler is found for a named type. The lookup order should be:
- Exact type match (current behavior)
- Underlying type match (proposed enhancement)
- Default behavior (current fallback)
This would align with Go's type system where named types can be converted to their underlying types.
Alternative Considered
While implementing UnmarshalJSONV2
on each type works, it creates maintenance burden and code duplication, especially in codebases with many similar named types (IDs, codes, measurements, etc.).
Benefits
- Reduced boilerplate: One unmarshaler can handle multiple related types
- Better maintainability: Changes to parsing logic only need to be made in one place
- Consistency with Go's type system: Named types are convertible to their underlying types
- Backward compatibility: Existing code continues to work unchanged
This enhancement would make JSON v2 more developer-friendly while maintaining type safety and performance.
Comment From: gabyhelp
Related Issues
- encoding/json: unable to set options when unmarshaling #17654 (closed)
- proposal: encoding/json: ability to unmarshal primitives as string #38516 (closed)
- encoding/json: clarify Unmarshal behavior for string keys that implement encoding.TextUnmarshaler #33298 (closed)
- encoding/json: confusing errors when unmarshaling custom types #28189
- encoding/json: should try to convert strings\<->numbers without errors if it's possible #22463 (closed)
- encoding/json: Unmarshal. Expected type string -> found int. #26928 (closed)
- proposal: encoding/json: wrap error from TextUnmarshaler/Unmarshaler to locate the problem in input #58655
Related Code Changes
- encoding/json: json.Unmarshal fails to unmarshal a custom Unmarshaler struct field
- encoding/json: implement type override for serialization
- encoding/json: allow overriding type marshaling
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: mvdan
Personally, I'd be against this change. If the feature worked on underlying types, how would I add a custom marshaler for NamedInt
that wouldn't also affect every other int
or named int
type? Or similarly, how would I add a custom marshaler for int
which wouldn't affect other named int types, especially ones that I might not even be aware of?
Can you elaborate a bit on your use case? How have you arrived at a scenario where you seemingly have dozens of separate named types with the same underlying type which should all be marshaled in the same custom way? At that point, my intuition is that you should do either of...
1) use the same named type for all of these cases
2) embed the same named type in each of these types, like customIntMarshaler
, with the json/v2 marshal method so that you don't have to repeat it
Comment From: flyhope
Personally, I'd be against this change. If the feature worked on underlying types, how would I add a custom marshaler for
NamedInt
that wouldn't also affect every otherint
or namedint
type? Or similarly, how would I add a custom marshaler forint
which wouldn't affect other named int types, especially ones that I might not even be aware of?Can you elaborate a bit on your use case? How have you arrived at a scenario where you seemingly have dozens of separate named types with the same underlying type which should all be marshaled in the same custom way? At that point, my intuition is that you should do either of...
- use the same named type for all of these cases
- embed the same named type in each of these types, like
customIntMarshaler
, with the json/v2 marshal method so that you don't have to repeat it
Thanks for the feedback! Let me clarify the use case and address your concerns.
Use Case: Go-PHP Integration
Our project involves Go services calling PHP APIs. Due to PHP's weak typing nature, we face a common integration challenge:
- Go side: We define structured types like
type UserID int
,type ProductID int
,type CategoryID int
for type safety - PHP side: Returns inconsistent JSON - sometimes numbers as integers (
"user_id": 123
), sometimes as strings ("user_id": "123"
) depending on data source (direct values vs MySQL results)
This is similar to what github.com/json-iterator/go/extra.RegisterFuzzyDecoders()
solves - providing "fuzzy" parsing that accepts both string and numeric representations for integer fields.
Addressing Your Concerns
How would I add a custom marshaler for
NamedInt
that wouldn't also affect every otherint
?
The proposed lookup order preserves this capability:
1. Exact type match (highest priority) - your NamedInt
specific marshaler
2. Underlying type match - fallback for types without specific marshalers
3. Default behavior
So if you register both:
json.UnmarshalFromFunc(specificNamedIntHandler) // for NamedInt
json.UnmarshalFromFunc(genericIntHandler) // for int
The specific handler takes precedence.
How would I add a custom marshaler for
int
which wouldn't affect other named int types?
Currently this isn't possible anyway - you'd need to register for each named type individually. The proposal doesn't change this limitation, but makes the common case (wanting the same behavior) easier.
Why Not Your Suggested Alternatives?
-
"Use the same named type": We can't - these represent different domain concepts (
UserID
,ProductID
, etc.) that should remain distinct types for API safety. -
"Embed the same named type": This breaks the simple integer semantics we need and complicates the API surface.
Real-World Impact
Consider a typical PHP API response:
{
"user_id": "123", // string from MySQL
"product_id": 456, // number from cache
"category_id": "789" // string from join query
}
Currently requires:
// Verbose registration for every ID type
unmarshalers := json.NewUnmarshalers(
json.UnmarshalFromFunc(fuzzyInt[UserID]),
json.UnmarshalFromFunc(fuzzyInt[ProductID]),
json.UnmarshalFromFunc(fuzzyInt[CategoryID]),
json.UnmarshalFromFunc(fuzzyInt[OrderID]),
// ... dozens more in real applications
)
With the proposal:
// Single registration handles all int-based types
unmarshalers := json.NewUnmarshalers(
json.UnmarshalFromFunc(fuzzyIntParser), // applies to all int-derived types
)
Precedent
This pattern exists successfully in:
- json-iterator/go/extra.RegisterFuzzyDecoders()
- Many other language JSON libraries that apply transformations based on target type
The feature would be opt-in via explicit unmarshaler registration, not automatic behavior, so it wouldn't break existing code or cause unexpected behavior.
Alternative Implementation Approach
To address backward compatibility concerns, we could preserve the existing json.UnmarshalFromFunc
behavior and add a new function:
// Existing function - exact type matching only (unchanged)
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers
// New function - matches underlying types
func UnmarshalFromFuncOriginType[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers
This approach:
- Preserves existing behavior - UnmarshalFromFunc
remains unchanged
- Explicit opt-in - developers choose when they want underlying type matching
- Clear naming - the intent is obvious from the function name
- No breaking changes - existing code continues to work exactly as before
Usage example:
// For exact type matching (current behavior)
json.UnmarshalFromFunc(specificNamedIntHandler)
// For underlying type matching (new behavior)
json.UnmarshalFromFuncOriginType(fuzzyIntParser)
This gives developers the flexibility to choose the appropriate behavior for their use case while maintaining full backward compatibility.
Does this help clarify the use case and address your concerns about type safety?
Comment From: mvdan
2. "Embed the same named type": This breaks the simple integer semantics we need and complicates the API surface.
Can you expand on this? You say that reusing a core "marshaler" type via embedding is not simple, but adding a new feature to a standard library package needs a better argument than that. As far as I can imagine, embedding a shared type is just one added line per type. That should only really be a problem if you're writing hundreds of near-identical types by hand, which on its own feels rather odd.