Skip to content
gohacks.dev
APIs

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.

11 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

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

types.goGo
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

main.goGo
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)
}
Text
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:

Text
{"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, because UnmarshalText only 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 %w the underlying error.
  • A bad duration or Unix time: errors from ParseDuration and 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:

Go
type plain Loop

A 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

types_test.goGo
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)
}
Text
=== 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.227s

The 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.

Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.