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:

  1. 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
)
  1. 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:

  1. Exact type match (current behavior)
  2. Underlying type match (proposed enhancement)
  3. 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

  1. Reduced boilerplate: One unmarshaler can handle multiple related types
  2. Better maintainability: Changes to parsing logic only need to be made in one place
  3. Consistency with Go's type system: Named types are convertible to their underlying types
  4. 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

Related Code Changes

(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 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

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 other int?

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?

  1. "Use the same named type": We can't - these represent different domain concepts (UserID, ProductID, etc.) that should remain distinct types for API safety.

  2. "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.