- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You're consuming a webhook feed. Every event arrives on the same
endpoint, wrapped in the same envelope, but the data field is a
different shape depending on type. A payment.succeeded carries an
amount and a currency. A customer.updated carries an email and a
name. A subscription.canceled carries a reason code.
The naive Go move is to decode the whole thing into
map[string]any, reach into it, and type-assert your way to the
fields you want. That works right up until you're three levels deep
in payload["data"].(map[string]any)["amount"].(float64) and every
line is a panic waiting for a malformed event.
There's a stdlib type built for exactly this: json.RawMessage. It
lets you decode in two passes. First pass reads the discriminator.
Second pass decodes the payload into the concrete struct you now
know it should be. No map[string]any, no chained assertions.
What json.RawMessage actually is
json.RawMessage is a defined type over []byte:
type RawMessage []byte
It implements json.Unmarshaler and json.Marshaler. When the
decoder hits a field typed as RawMessage, it doesn't parse the
value. It copies the raw bytes verbatim and hands them to you. The
JSON stays as JSON. You decide when and how to parse it.
That "decide later" is the whole point. You get to look at one field,
branch on it, and then decode the rest with full type information.
The envelope, decoded in two passes
Here's the webhook shape. The envelope has a type string and a
data blob whose structure depends on that type.
package main
import (
"encoding/json"
"fmt"
)
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
type PaymentSucceeded struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type CustomerUpdated struct {
Email string `json:"email"`
Name string `json:"name"`
}
The first Unmarshal fills in Type and leaves Data as raw
bytes. You read Type, then run a second Unmarshal on Data
into the matching struct.
func handle(raw []byte) error {
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("envelope: %w", err)
}
switch env.Type {
case "payment.succeeded":
var p PaymentSucceeded
if err := json.Unmarshal(env.Data, &p); err != nil {
return fmt.Errorf("payment: %w", err)
}
fmt.Printf("charged %d %s\n", p.Amount, p.Currency)
case "customer.updated":
var c CustomerUpdated
if err := json.Unmarshal(env.Data, &c); err != nil {
return fmt.Errorf("customer: %w", err)
}
fmt.Printf("customer %s (%s)\n", c.Name, c.Email)
default:
return fmt.Errorf("unknown type %q", env.Type)
}
return nil
}
Every field access above is statically typed. p.Amount is an
int64, not an any you have to assert. If the payload is
malformed, the error comes back from Unmarshal with a message that
tells you which type failed, instead of a nil-map panic buried in a
handler.
Run it with a real payment event:
func main() {
raw := []byte(`{
"type": "payment.succeeded",
"data": {"amount": 4200, "currency": "eur"}
}`)
if err := handle(raw); err != nil {
fmt.Println("error:", err)
}
}
Output:
charged 4200 eur
Why this beats map[string]any
The map[string]any version of handle looks shorter at first, and
that's the trap. Compare the field access:
// interface{} soup
m := payload["data"].(map[string]any)
amount := int64(m["amount"].(float64))
currency := m["currency"].(string)
Three problems live in those three lines. JSON numbers decode to
float64, so amount goes through a lossy float64 on the way to
int64. Any assertion that's wrong panics instead of returning an
error. And nothing documents the shape of a payment event; the
structure only exists in your head.
The RawMessage version moves all of that into a struct with tags.
The shape is written down. The numbers land in the type you declared.
The failures come back as errors you can wrap and log.
Marshaling back out
RawMessage works in the encode direction too. If you have a
pre-serialized blob you want to embed without re-parsing it, store it
as a RawMessage and the encoder writes it through untouched.
type Response struct {
Status string `json:"status"`
Result json.RawMessage `json:"result"`
}
func main() {
cached := json.RawMessage(`{"id":7,"ok":true}`)
out, _ := json.Marshal(Response{
Status: "done",
Result: cached,
})
fmt.Println(string(out))
}
Output:
{"status":"done","result":{"id":7,"ok":true}}
The result object was never decoded and re-encoded. That matters
when you're proxying a payload you don't own and don't want to
accidentally reorder keys or drop fields you didn't model.
One gotcha: a pointer field skips the copy
If you declare the field as *json.RawMessage instead of
json.RawMessage, a missing key leaves the pointer nil and a JSON
null sets it to a RawMessage holding the literal bytes null.
That distinction is sometimes what you want (telling "absent" from
"explicitly null" apart), but reach for it deliberately. The plain
value form is the default you want for the discriminator pattern.
There's a second subtlety. RawMessage holds a slice into the
buffer the decoder was reading, so treat it as read-only and decode
it before the underlying bytes get reused. In the two-pass flow above
you decode immediately, so this never bites. If you stash a
RawMessage on a struct and hold it across goroutines, copy it
first with slices.Clone.
When to reach for a custom UnmarshalJSON instead
The two-pass switch is the right call when the caller drives the
branch: you read the type, you pick the struct, you decode. When you
want a single type that decodes itself polymorphically, push the same
logic into an UnmarshalJSON method on a wrapper type.
type Event struct {
Type string
Payload any
}
func (e *Event) UnmarshalJSON(b []byte) error {
var env Envelope
if err := json.Unmarshal(b, &env); err != nil {
return err
}
e.Type = env.Type
switch env.Type {
case "payment.succeeded":
var p PaymentSucceeded
if err := json.Unmarshal(env.Data, &p); err != nil {
return err
}
e.Payload = p
case "customer.updated":
var c CustomerUpdated
if err := json.Unmarshal(env.Data, &c); err != nil {
return err
}
e.Payload = c
default:
return fmt.Errorf("unknown type %q", env.Type)
}
return nil
}
Now json.Unmarshal(raw, &event) handles the two passes internally,
and callers get an Event with a typed Payload. The any is back,
but it's contained: it lives in one field, populated by one method,
and every branch put a known concrete type in it. That's a different
thing from spraying any across your whole decode path.
Which one you pick is a boundary question. If the polymorphism is an
implementation detail of one type, hide it behind UnmarshalJSON. If
the caller genuinely needs to fan out to different handlers, keep the
switch in the caller where the branching is visible.
The pattern in one line
Type the discriminator eagerly, type the payload lazily. RawMessage
is the stdlib's way of saying "hold these bytes, I'll tell you what
they are in a second." It's the difference between a decoder that
fails with a wrapped error on line 3 and one that panics inside a
map assertion two weeks after you shipped it.
Go's encoding/json gives you the two-pass tools; the discipline is
deciding where the branch lives. The Complete Guide to Go
Programming goes deep on the Marshaler/Unmarshaler interfaces
and how the decoder walks a struct, and Hexagonal Architecture in
Go is about keeping decisions like "where does the polymorphism
live" at the right boundary instead of leaking any through your
domain.

Top comments (0)