go 33 lines · 1 tab

Strict JSON time parsing with custom UnmarshalJSON

Leah Thompson Jan 2026
1 tab
package types

import (
  "bytes"
  "encoding/json"
  "time"
)

type RFC3339Time struct{ time.Time }

func (t *RFC3339Time) UnmarshalJSON(b []byte) error {
  if bytes.Equal(b, []byte("null")) {
    t.Time = time.Time{}
    return nil
  }
  var s string
  if err := json.Unmarshal(b, &s); err != nil {
    return err
  }
  parsed, err := time.Parse(time.RFC3339, s)
  if err != nil {
    return err
  }
  t.Time = parsed
  return nil
}

func (t RFC3339Time) MarshalJSON() ([]byte, error) {
  if t.Time.IsZero() {
    return []byte("null"), nil
  }
  return json.Marshal(t.Time.Format(time.RFC3339))
}
1 file · go Explain with highlit

Time parsing bugs are common in APIs: clients send different formats, empty strings, or timezone-less values. I like making time parsing explicit by wrapping time.Time and implementing UnmarshalJSON. The wrapper accepts RFC3339, treats null as “unset,” and rejects anything else. That gives you clear 400 errors instead of weird “zero time” values drifting into the database. I also implement MarshalJSON so the server emits a consistent format, which makes caching and client parsing easier. The key is to keep semantics tight: if a field is optional, make it a pointer or allow null; if it’s required, validate it. This pattern prevents subtle bugs in scheduling and billing systems where a single timezone mistake can cause real customer impact.


Related snips

Share this code

Here's the card — post it anywhere.

Strict JSON time parsing with custom UnmarshalJSON — share card
Link copied