Custom JSON Marshalers in Go for Enums and Timestamps
Write JSON marshalers in Go for enums, dates, durations and Unix times: MarshalText versus MarshalJSON, clear errors, the pointer receiver trap and recursion.

The defaults in encoding/json suit data that looks like Go: structs, slices, strings, numbers. They suit a public API less well. An enum stored as 2 is a magic number to every client. A time.Time where you wanted a calendar date carries a midnight and a timezone nobody asked for. A time.Duration is a count of nanoseconds. Each of those can be fixed at the edge by giving the type its own encoding, so the rest of the program keeps the Go type and the wire format is a deliberate choice.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds four such types: an enum, a date, a duration and a Unix timestamp. It covers which interface to implement, how to make errors useful, and two traps that catch nearly everyone: receivers and recursion. It builds on the tour of marshaling and feeds into three-state fields, and the types are the kind of thing request validation leans on. Everything ran on Go 1.25.6.
Two interfaces, and which one to pick
There are two ways to take over a type's encoding.
json.Marshaler is MarshalJSON() ([]byte, error), and json.Unmarshaler is UnmarshalJSON([]byte) error. They control the JSON representation directly, so use them when the wire format is a different JSON kind from what the type naturally gives you: a date that is a string, a timestamp that is a bare number.
encoding.TextMarshaler is MarshalText() ([]byte, error), with UnmarshalText. The type says how it becomes text, and encoding/json writes that as a JSON string. Use these for anything whose natural form is a string, and especially for enums. Text marshalers have one extra property that matters: they work as map keys. encoding/json requires map keys to be strings, integers or text-marshalers, so a map[Status]int can be encoded only if Status implements MarshalText. The same methods are used by flag.TextVar and by many configuration libraries, so one implementation serves them all.
The rule I follow: enums and string-like values get the text pair; types that change the JSON kind get the JSON pair.
The types
package main
import (
"encoding"
"fmt"
"strings"
"time"
)
// Status is an enum stored as an int in the program and written as a word on the wire.
type Status int
const (
Draft Status = iota + 1 // zero is deliberately not a valid status
Published
Archived
)
var statusNames = map[Status]string{Draft: "draft", Published: "published", Archived: "archived"}
func (s Status) String() string { return statusNames[s] }
// MarshalText makes Status work in JSON values, JSON map keys, flags, YAML and database drivers that look for TextMarshaler.
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("invalid status %d", int(s))
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for v, name := range statusNames {
if strings.EqualFold(name, string(b)) {
*s = v
return nil
}
}
return fmt.Errorf("unknown status %q (want draft, published or archived)", b)
}
var (
_ encoding.TextMarshaler = Status(0)
_ encoding.TextUnmarshaler = (*Status)(nil)
)
// Date is a calendar date with no time of day, written as 2006-01-02.
type Date struct{ time.Time }
const dateLayout = "2006-01-02"
func (d Date) MarshalJSON() ([]byte, error) {
if d.IsZero() {
return []byte("null"), nil
}
return []byte(`"` + d.Format(dateLayout) + `"`), nil
}
func (d *Date) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "null" || s == "" {
return nil
}
t, err := time.Parse(dateLayout, s)
if err != nil {
return fmt.Errorf("date must look like 2006-01-02: %w", err)
}
d.Time = t
return nil
}
// Seconds is a duration exchanged as a human string such as "1.5s" instead of nanoseconds.
type Seconds time.Duration
func (d Seconds) MarshalJSON() ([]byte, error) {
return []byte(`"` + time.Duration(d).String() + `"`), nil
}
func (d *Seconds) UnmarshalJSON(b []byte) error {
v, err := time.ParseDuration(strings.Trim(string(b), `"`))
if err != nil {
return err
}
*d = Seconds(v)
return nil
}
// UnixTime is a timestamp exchanged as integer seconds.
type UnixTime struct{ time.Time }
func (u UnixTime) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprint(u.Unix())), nil }
func (u *UnixTime) UnmarshalJSON(b []byte) error {
var n int64
if _, err := fmt.Sscan(string(b), &n); err != nil {
return fmt.Errorf("unix time must be an integer: %w", err)
}
u.Time = time.Unix(n, 0).UTC()
return nil
}Status is an int enum with three values starting at iota + 1. Leaving zero unused is deliberate: an uninitialised field must not silently become a valid status. MarshalText looks up the name and returns an error for anything not in the table, and UnmarshalText matches case-insensitively and, for an unknown name, returns an error that lists the choices. The compile-time assertions at the bottom (var _ encoding.TextMarshaler = Status(0)) cost nothing and turn a typo in a method name into a build error, instead of a silent fallback to encoding the raw integer.
Date wraps time.Time and writes 2006-01-02. A date-only field is the right type for a due date, a birthday or a billing day. Using a full timestamp for those invites timezone arguments: a 2026-12-31T00:00:00Z shown in a UTC-5 client becomes the thirtieth. The zero date marshals to null and decodes from null or an empty string without error, so an optional date works; the layout is a constant that appears once.
Seconds is a time.Duration exchanged as a string, so 1500 * time.Millisecond is "1.5s". The standard library's time.ParseDuration does the reading, including units, and its error message (time: invalid duration "soon") is already good. A bare integer needs a documented unit; a string with the unit in it carries the unit with it.
UnixTime wraps time.Time and exchanges integer seconds, the format of many third-party APIs and of JWT claims. Decoding builds a UTC time, so the timezone does not leak in.
What the program printed
package main
import (
"encoding/json"
"fmt"
"time"
)
type Post struct {
Title string `json:"title"`
Status Status `json:"status"`
Due Date `json:"due"`
Timeout Seconds `json:"timeout"`
Created UnixTime `json:"created"`
ByStatus map[Status]int `json:"by_status,omitempty"`
}
// PointerOnly shows the receiver trap: its marshaler has a pointer receiver.
type PointerOnly struct{ n int }
func (p *PointerOnly) MarshalJSON() ([]byte, error) { return []byte(`"custom"`), nil }
// Recursive shows the other trap, and the type-alias way out.
type Loop struct{ A int }
func (l Loop) MarshalJSON() ([]byte, error) {
type plain Loop // no methods, so no recursion
return json.Marshal(struct {
plain
Kind string `json:"kind"`
}{plain(l), "loop"})
}
func main() {
p := Post{
Title: "Hello", Status: Published,
Due: Date{time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)},
Timeout: Seconds(1500 * time.Millisecond),
Created: UnixTime{time.Unix(1_800_000_000, 0).UTC()},
ByStatus: map[Status]int{Draft: 2, Published: 5},
}
b, _ := json.Marshal(p)
fmt.Printf("encoded: %s\n", b)
var back Post
err := json.Unmarshal(b, &back)
fmt.Printf("decoded: %v %v %v %v err=%v\n", back.Status, back.Due.Format("Jan 2, 2006"), time.Duration(back.Timeout), back.Created.Unix(), err)
fmt.Println("\n== bad input gets a useful message")
for _, in := range []string{
`{"status":"deleted"}`,
`{"status":2}`,
`{"due":"31/12/2026"}`,
`{"timeout":"soon"}`,
`{"created":"yesterday"}`,
} {
var x Post
fmt.Printf("%-28s %v\n", in, json.Unmarshal([]byte(in), &x))
}
fmt.Println("\n== an invalid enum value cannot be written")
_, err = json.Marshal(Post{Status: Status(9)})
fmt.Println(err)
_, err = json.Marshal(Post{})
fmt.Println("zero Status:", err)
fmt.Println("\n== the pointer receiver trap")
v, _ := json.Marshal(struct{ P PointerOnly }{PointerOnly{1}})
ptr, _ := json.Marshal(&struct{ P PointerOnly }{PointerOnly{1}})
fmt.Printf("value: %s\npointer: %s\n", v, ptr)
fmt.Println("\n== extending a type without recursing")
l, _ := json.Marshal(Loop{7})
fmt.Printf("%s\n", l)
}encoded: {"title":"Hello","status":"published","due":"2026-12-31","timeout":"1.5s","created":1800000000,"by_status":{"draft":2,"published":5}}
decoded: published Dec 31, 2026 1.5s 1800000000 err=<nil>
== bad input gets a useful message
{"status":"deleted"} unknown status "deleted" (want draft, published or archived)
{"status":2} json: cannot unmarshal number into Go struct field Post.status of type main.Status
{"due":"31/12/2026"} date must look like 2006-01-02: parsing time "31/12/2026" as "2006-01-02": cannot parse "31/12/2026" as "2006"
{"timeout":"soon"} time: invalid duration "soon"
{"created":"yesterday"} unix time must be an integer: expected integer
== an invalid enum value cannot be written
json: error calling MarshalText for type main.Status: invalid status 9
zero Status: json: error calling MarshalText for type main.Status: invalid status 0
== the pointer receiver trap
value: {"P":{}}
pointer: {"P":"custom"}
== extending a type without recursing
{"A":7,"kind":"loop"}The first line is a Post using all four types, plus a map[Status]int:
{"title":"Hello","status":"published","due":"2026-12-31","timeout":"1.5s","created":1800000000,"by_status":{"draft":2,"published":5}}Every field is readable by a person. The map has word keys, which only works because of MarshalText. The second line decodes that back into a Post and prints each field's Go value, with no error, so the round trip is lossless.
The block of bad inputs shows the quality of the errors, which is most of the value in a custom unmarshaler:
- An unknown status name:
unknown status "deleted" (want draft, published or archived). The message tells the client what to send. - A number where the enum expects a string: the standard error,
cannot unmarshal number into Go struct field Post.status of type main.Status, becauseUnmarshalTextonly sees strings. If you also want to accept numbers for compatibility, implement the JSON pair instead and handle both kinds. - A badly formatted date:
date must look like 2006-01-02: parsing time .... Wrap with the expected format first, then%wthe underlying error. - A bad duration or Unix time: errors from
ParseDurationand from the integer parse, each with enough text to understand.
Compare those with what a client sees if you skip the custom types and take a string field: a validation message somewhere later, or no message at all. The decode step is the cheapest place to reject a malformed value.
Writing has its own failure mode. json.Marshal(Post{Status: Status(9)}) returns json: error calling MarshalText for type main.Status: invalid status 9, and so does Post{}, whose status is zero. That is the point of an invalid zero value: a handler that forgot to set the status fails in testing instead of sending a made-up word. It also means a zero Post cannot be marshaled, which is a real cost. If your model needs a legal "unset" state, either give the enum an Unknown value written as null or "unknown", or tag the field omitzero so an unset status is left out. Decide on purpose; do not let the default decide.
Trap one: receivers
The program's "pointer receiver trap" shows a method defined on *PointerOnly. Marshaling a struct that contains a PointerOnly by value produced {"P":{}}, which means the custom method was not called, and the field was encoded as an empty struct (it has only an unexported field). Marshaling a pointer to the same struct produced {"P":"custom"}. The standard library can call a pointer-receiver method only when the value is addressable, and a value passed to Marshal by copy is not.
The fix is simple: give marshalers value receivers. func (d Date) MarshalJSON(), as above. Unmarshalers are the opposite and must have pointer receivers, since they have to modify the value. The mixed pair, a value-receiver marshaler and a pointer-receiver unmarshaler, is the normal shape.
Trap two: recursion
A marshaler that wants to add or change a field of its own type is tempted to call json.Marshal(l) inside MarshalJSON, and that calls MarshalJSON again, forever. The standard escape is a local type declaration with no methods:
type plain LoopA defined type gets its underlying structure but not the methods, so json.Marshal(plain(l)) uses the default encoding. In the program, Loop.MarshalJSON embeds plain in an anonymous struct together with a Kind field and the output is {"A":7,"kind":"loop"}. The same trick works in UnmarshalJSON to decode the default shape and then post-process it.
Tests
package main
import (
"encoding/json"
"testing"
"time"
)
func TestEveryStatusRoundTrips(t *testing.T) {
for s := range statusNames {
b, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var got Status
if err := json.Unmarshal(b, &got); err != nil || got != s {
t.Errorf("%s -> %v %v", b, got, err)
}
}
}
func TestStatusIsCaseInsensitiveOnInputButCanonicalOnOutput(t *testing.T) {
var s Status
if err := json.Unmarshal([]byte(`"PUBLISHED"`), &s); err != nil || s != Published {
t.Fatalf("%v %v", s, err)
}
if b, _ := json.Marshal(s); string(b) != `"published"` {
t.Fatalf("%s", b)
}
}
func TestDateRoundTripAndZero(t *testing.T) {
d := Date{time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)}
b, _ := json.Marshal(d)
if string(b) != `"2026-12-31"` {
t.Fatalf("%s", b)
}
var zero Date
if b, _ := json.Marshal(zero); string(b) != "null" {
t.Fatalf("%s", b)
}
}
func TestUnknownStatusErrorNamesTheChoices(t *testing.T) {
var s Status
err := json.Unmarshal([]byte(`"gone"`), &s)
if err == nil {
t.Fatal("accepted")
}
t.Log(err)
}=== RUN TestEveryStatusRoundTrips
--- PASS: TestEveryStatusRoundTrips (0.00s)
=== RUN TestStatusIsCaseInsensitiveOnInputButCanonicalOnOutput
--- PASS: TestStatusIsCaseInsensitiveOnInputButCanonicalOnOutput (0.00s)
=== RUN TestDateRoundTripAndZero
--- PASS: TestDateRoundTripAndZero (0.00s)
=== RUN TestUnknownStatusErrorNamesTheChoices
types_test.go:50: unknown status "gone" (want draft, published or archived)
--- PASS: TestUnknownStatusErrorNamesTheChoices (0.00s)
PASS
ok example.test/lab 1.227sThe round-trip test covers every status the table contains, so adding a value without a name fails immediately. The second checks that input is case-insensitive while output is canonical, which is the contract most clients want: liberal in what you accept, strict in what you send. The third pins the date format and the null for the zero date, and the fourth makes sure an unknown name is an error rather than a zero value. Keep an exact-bytes test for each custom type; the wire format is the part nobody should change by accident.
Further decisions
Where to put the types. In the same package as the model if they are domain concepts (a Status belongs to posts), in a small shared package if several services use them (dates, durations). Do not put HTTP-specific behavior inside them.
Database mapping. The same types often need Value and Scan for database/sql. Keep those separate from the JSON methods; the representations may differ, and an enum is often better stored as text or as a small integer with a lookup table.
Performance. Custom marshalers allocate a little, because they return a byte slice. For a type that appears in every row of a large response, benchmark it; in most services the cost vanishes beside the network. If it does not, the hook interfaces in JSON v2 are worth a look.
Do not hide validation in the marshaler. Marshal and unmarshal should be about format. Business rules (a published post must have a body) belong in validation, so one error does not appear from two places.
Third-party types. You cannot add methods to time.Time or a type from another package, so wrap it as above, or define a field-level type for the specific struct. Embedding time.Time in Date keeps its methods available.
The interfaces are documented in the encoding/json and encoding packages, and the behavior of map keys is spelled out in the json.Marshal documentation.


