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:
-
Type Uniqueness: Each timezone gets its own distinct type (
et.ET,pt.PT,utc.UTC). The type system can distinguishTime[et.ET]fromTime[pt.PT], making them incompatible. -
Zero-Cost Abstraction: The timezone type parameter exists only at compile time. At runtime, all
Time[TZ]types have identical memory layout—just atime.Timefield. The generic is erased, giving us type safety without runtime overhead. -
Extensibility: Anyone can define their own timezone by implementing the
Timezoneinterface. 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.