Timezones as Types: Making Time Safer to Use in Go | Matthew Halpern

11 min read Original article ↗

TL;DR Meridian uses Go generics to encode timezones directly into the type system (et.Time, pt.Time, etc.), catching timezone bugs at compile time instead of production.

Once you’ve encountered a timezone bug in production, you never forget it. A scheduled job runs five hours early. A financial report uses the wrong day boundaries. A payment system charges at midnight UTC instead of the customer’s local midnight.

If you’re a Golang developer who’s experienced this, you know the culprit: time.Time.

The problem is that timezone information can only be verified at runtime. A time.Time has no way to encode which timezone it represents. Let’s start with a simple example that looks correct but contains a subtle bug:

func scheduleFundsRelease(t time.Time) time.Time {
    midnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
    
    if t.After(midnight) {
        midnight = midnight.AddDate(0, 0, 1)
    }

    return midnight
}

func main() {
    et, _ := time.LoadLocation("America/New_York")
    etTime :=  time.Now().In(et)

    releaseTime := scheduleFundsRelease(etTime)
    
    // Store in database (most databases store in UTC)
    db.Save(releaseTime.UTC())
    
    // ... later, in another service ...
    
    var releaseTime time.Time
    db.Load(&releaseTime)  // Loads as UTC
    
    // When is midnight? UTC? New York? Nobody knows anymore.
    if time.Now().After(releaseTime) {
        releaseFunds()
    }
}

The timezone information is gone. The time.Time value we loaded from the database has no idea it was originally meant to represent New York time. As far as the type system is concerned, it’s just a moment in time, floating in UTC.

This pattern of losing timezone context through serialization, function boundaries, or transformations—is surprisingly common. Once you call .UTC() or pass a time through a function that doesn’t preserve location information or even changes it, the compiler can’t help you verify correctness.

What If Timezones Were Part of the Type?

Imagine if instead of writing:

func scheduleFundsRelease(t *time.Time) time.Time

We could write:

func scheduleFundsRelease(t et.Time) et.Time

Now the timezone isn’t just data — it’s part of the type signature. The compiler knows this function returns Eastern time, and that information can’t be accidentally lost.

Introducing Meridian: Timezones as Types

Meridian is a library that uses Go’s generics (introduced in 1.18) to encode timezone information directly into the type system.

Instead of having a single timezone package, Meridian provides the functionality each timezone its own package:

import (
    "github.com/matthalp/go-meridian"
    "github.com/matthalp/go-meridian/et"
    "github.com/matthalp/go-meridian/pt"
    "github.com/matthalp/go-meridian/utc"
)

// The package names make intent crystal clear
var eastCoastRelease et.Time
var westCoastRelease pt.Time
var dbStorageTime utc.Time

Supported Timezones

Meridian provides packages for the following timezones out of the box:

  • github.com/matthalp/go-meridian/aest - Australian Eastern Time (Australia/Sydney)
  • github.com/matthalp/go-meridian/brt - Brasília Time (America/Sao_Paulo)
  • github.com/matthalp/go-meridian/cet - Central European Time (Europe/Paris)
  • github.com/matthalp/go-meridian/cst - China Standard Time (Asia/Shanghai)
  • github.com/matthalp/go-meridian/ct - Central Time (America/Chicago)
  • github.com/matthalp/go-meridian/et - Eastern Time (America/New_York)
  • github.com/matthalp/go-meridian/gmt - Greenwich Mean Time (Europe/London)
  • github.com/matthalp/go-meridian/hkt - Hong Kong Time (Asia/Hong_Kong)
  • github.com/matthalp/go-meridian/ist - India Standard Time (Asia/Kolkata)
  • github.com/matthalp/go-meridian/jst - Japan Standard Time (Asia/Tokyo)
  • github.com/matthalp/go-meridian/mt - Mountain Time (America/Denver)
  • github.com/matthalp/go-meridian/pt - Pacific Time (America/Los_Angeles)
  • github.com/matthalp/go-meridian/sgt - Singapore Time (Asia/Singapore)
  • github.com/matthalp/go-meridian/utc - Coordinated Universal Time

Meridian includes a code generator that makes it easy to add new timezone packages as needed.

Each timezone package provides idiomatic helpers:

// Instead of meridian.Now[et.ET]()
now := et.Now()

// Instead of meridian.Date[pt.PT](2024, 1, 15, 9, 0, 0, 0)
releaseTime := pt.Date(2024, 1, 15, 9, 0, 0, 0)

// Parse in the timezone's location
parsed, _ := et.Parse(time.RFC3339, "2024-01-15T09:00:00-05:00")

Type Signatures Become Self-Documenting

// Before: What timezone is this?
func ReleaseFunds(scheduledTime time.Time, tz *time.Location) error

// After: The type tells you everything
func ReleaseFunds(scheduledTime et.Time) error

Wrong Code Is Impossible to Compile

Here’s what happens when you try to mix timezones:

func releaseFunds(t et.Time) {
    // This is always in ET, guaranteed by the type system
    if t.Hour() >= 9 && t.Hour() < 17 {
        // Release funds during business hours only
    }
}

var utcTime utc.Time = utc.Now()
releaseFunds(utcTime)  // ❌ Compile error: cannot use utc.Time as et.Time

The compiler catches the bug before it reaches production. To make this work, you must be explicit:

releaseFunds(et.FromMoment(utcTime))  // ✅ Explicit conversion is visible

All the Methods You Know and Love

Meridian provides the complete time.Time API and supporting factory methods, but type-safe:

func getNextReleaseDay(t et.Time) et.Time {
    tomorrow := t.Add(24 * time.Hour)  // Still et.Time
    
    if tomorrow.Weekday() == time.Saturday {
        return tomorrow.Add(48 * time.Hour)  // Still et.Time
    }
    if tomorrow.Weekday() == time.Sunday {
        return tomorrow.Add(24 * time.Hour)  // Still et.Time
    }
    
    return tomorrow  // Type is preserved throughout
}

Time arithmetic, formatting, parsing—everything works, and the timezone type is preserved:

scheduledRelease := et.Date(2024, 3, 15, 14, 30, 0, 0)
fmt.Println(scheduledRelease.Format("3:04 PM MST"))  // "2:30 PM EST"

nextRelease := scheduledRelease.AddDate(0, 0, 7)  // Still et.Time
year, month, day := nextRelease.Date()   // In ET

Supporting Operations Across Timezones

One challenge with introducing type-safe timezones is comparing times across different zones and interoperating with time.Time. Meridian solves this through an interface:

// Any Time[TZ] and time.Time implements Moment
type Moment interface {
    UTC() time.Time
}

// This enables natural comparisons
var eastCoastRelease et.Time = et.Date(2024, 12, 25, 14, 0, 0, 0)
var westCoastRelease pt.Time = pt.Date(2024, 12, 25, 16, 0, 0, 0)

if eastCoastRelease.After(westCoastRelease) {  // Just works!
    // East coast release happens after west coast release
}

// Even works with regular time.Time
var standardTime time.Time = time.Now()
if eastCoastRelease.Before(standardTime) {  // Also works!
    // Seamless interoperability
}

Converting Between Timezones

All conversions are explicit using FromMoment:

// East coast customer release time
eastRelease := et.Date(2024, 12, 25, 9, 0, 0, 0)
fmt.Println("ET:", eastRelease.Format("15:04 MST"))  // "09:00 EST"

// West coast customer gets funds at the same moment
westRelease := pt.FromMoment(eastRelease)
fmt.Println("PT:", westRelease.Format("15:04 MST"))  // "06:00 PST"

// Store in database as UTC
dbTime := utc.FromMoment(eastRelease)
fmt.Println("UTC:", dbTime.Format("15:04 MST"))  // "14:00 UTC"

// All represent the same moment in time for financial reconciliation
fmt.Println(eastRelease.Equal(westRelease))  // true
fmt.Println(westRelease.Equal(dbTime))       // true

From standard time.Time:

stdTime := time.Now()

// Convert to typed timezones
utcTyped := utc.FromMoment(stdTime)
etTyped := et.FromMoment(stdTime)
ptTyped := pt.FromMoment(stdTime)

Real-World Example: Scheduled Fund Releases

A common pattern in production is storing times as UTC in the database but working with them in customer timezones for business logic:

// Storing scheduled fund releases in the database
type ScheduledRelease struct {
    CustomerID int
    Amount     int
    ReleaseAt  utc.Time  // Database times in UTC
}

// Business logic works in customer timezone
func scheduleMonthlyRelease(customerID int, amount int) error {
    // Customer is in Eastern timezone
    nextMonth := et.Now().AddDate(0, 1, 0)
    midnight := et.Date(nextMonth.Year(), nextMonth.Month(), 1, 0, 0, 0, 0)
    
    release := ScheduledRelease{
        CustomerID: customerID,
        Amount:     amount,
        ReleaseAt:  utc.FromMoment(midnight),  // Explicit conversion for storage
    }
    
    return db.Insert(release)
}

// When processing releases
func processRelease(release ScheduledRelease) error {
    // Convert back to customer's timezone for business logic
    etTime := et.FromMoment(release.ReleaseAt)
    
    return releaseFunds(release.CustomerID, release.Amount)
}

The type system guides you: store as utc.Time, convert explicitly when needed, and the compiler ensures you never accidentally mix timezones.

How It Works Under the Hood

The design of Meridian revolves around three key decisions that work together to provide both type safety and ergonomic usage. Let’s explore each one and understand the reasoning behind it.

Design Decision 1: The Timezone Interface

At the heart of Meridian is this simple generic type and interface:

type Time[TZ Timezone] struct {
    utcTime time.Time
}

type Timezone interface {
    Location() *time.Location
}

The interface exists purely as a type-level marker: each timezone package defines its own zero-size type that implements this interface:

// et/et.go
package et

import (
	"fmt"
	"time"

	"github.com/matthalp/go-meridian"
)

// location is the IANA timezone location, loaded once at package initialization.
var location = mustLoadLocation("America/New_York")

// mustLoadLocation loads a timezone location or panics if it fails.
// This should only fail if the system's timezone database is corrupted or missing.
func mustLoadLocation(name string) *time.Location {
	loc, err := time.LoadLocation(name)
	if err != nil {
		panic(fmt.Sprintf("failed to load timezone %s: %v", name, err))
	}
	return loc
}

// Timezone represents the Eastern Time zone.
type Timezone struct{}

// Location returns the IANA timezone location.
func (Timezone) Location() *time.Location {
	return location
}

This design achieves three critical goals:

  1. Type Uniqueness: Each timezone gets its own distinct type (et.ET, pt.PT, utc.UTC). The type system can distinguish Time[et.ET] from Time[pt.PT], making them incompatible.

  2. Zero-Cost Abstraction: The timezone type parameter exists only at compile time. At runtime, all Time[TZ] types have identical memory layout—just a time.Time field. The generic is erased, giving us type safety without runtime overhead.

  3. Extensibility: Anyone can define their own timezone by implementing the Timezone interface. You’re not limited to a predefined enum.

The interface itself is never used for runtime behavior—it’s purely a compile-time mechanism to carry timezone information in the type system.

Load Once, Use Forever: Package-Level Location Loading

Each timezone package loads its location once at initialization and caches it for the lifetime of the program:

// location is loaded once at package initialization
var location = mustLoadLocation("America/New_York")

func mustLoadLocation(name string) *time.Location {
    loc, err := time.LoadLocation(name)
    if err != nil {
        panic(fmt.Sprintf("failed to load timezone %s: %v", name, err))
    }
    return loc
}

// Location() simply returns the pre-loaded location
func (Timezone) Location() *time.Location {
    return location
}

This pattern has several advantages:

1. Load Once, Zero Overhead Later

The location is loaded when the package is first imported. After that, every call to Location() is just a field access—no lookups, no allocations, no error handling.

2. Fail Fast on Missing Timezones

If the timezone database is missing or corrupted, the program panics at startup rather than failing mysteriously during runtime. This is much easier to debug than intermittent errors deep in business logic.

3. Bridging Type-Level and Runtime

To use this location from the generic Time[TZ] type, Meridian uses a simple wrapper:

func getLocation[TZ Timezone]() *time.Location {
    var tz TZ
    return tz.Location()
}

This bridges the gap between type parameters (compile-time) and values (runtime). In Go generics, TZ is a type, not a value. We can’t call methods on a type directly, so we instantiate a zero-value of TZ and call its method:

func (t Time[TZ]) Hour() int {
    loc := getLocation[TZ]()  // Bridge: type parameter → location
    return t.utcTime.In(loc).Hour()
}

Since location is already loaded, this is effectively free at runtime—the generic parameter is resolved at compile time, and the location lookup is just a memory access.

Design Decision 2: Storing Time as UTC Internally

Look closely at the Time struct:

type Time[TZ Timezone] struct {
    utcTime time.Time  // Always stored in UTC
}

Why store every time as UTC internally, regardless of its timezone type?

This decision is driven by Go’s zero value semantics. In Go, every type must have a sensible zero value that you can use without initialization:

var release et.Time  // Zero value must be valid
fmt.Println(release.IsZero())  // Should work without panicking

If we tried to store the timezone’s *time.Location as a field:

// Problematic design ❌
type Time[TZ Timezone] struct {
    time time.Time
    loc  *time.Location  // Would be nil for zero value!
}

The zero value would have a nil location pointer, causing panics. By storing everything as UTC and deriving the timezone from the type parameter, the zero value is always valid:

var t et.Time  // Zero value: January 1, year 1, 00:00:00 UTC
// Can safely call any method without initialization

Design Decision 3: Cross-Timezone Operations via the Moment Interface

With strong timezone types, how do you compare times across different timezones? How do you enable et.Time to work with pt.Time or even standard time.Time?

The solution is the Moment interface:

type Moment interface {
    UTC() time.Time
}

Both Time[TZ] and the standard time.Time implement this interface. This enables natural comparisons:

var eastCoastRelease et.Time = et.Date(2024, 12, 25, 14, 0, 0, 0)
var westCoastRelease pt.Time = pt.Date(2024, 12, 25, 16, 0, 0, 0)

if eastCoastRelease.After(westCoastRelease) {  // Just works!
    // East coast release happens after west coast release
}

// Even works with regular time.Time
var standardTime time.Time = time.Now()
if eastCoastRelease.Before(standardTime) {  // Also works!
    // Seamless interoperability
}

Under the hood, comparisons use UTC, but you never have to think about it:

func (t Time[TZ]) After(u Moment) bool {
    return t.utcTime.After(u.UTC())
}

The type system prevents you from accidentally mixing timezones in function signatures, but once you need to compare across timezones, the interface provides a common ground. Every moment in time has a canonical UTC representation, and that’s what we use for comparisons—automatically and invisibly.

Try It Out for Yourself!

Install the package in your Go project:

go get github.com/matthalp/go-meridian

The Meridian source code is available at https://github.com/matthalp/go-meridian.