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() { est, _ := time.LoadLocation("America/New_York") estTime := time.Now().In(est) releaseTime := scheduleFundsRelease(estTime) // 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.TimeWe could write:
func scheduleFundsRelease(t est.Time) est.TimeNow 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/est" "github.com/matthalp/go-meridian/pst" "github.com/matthalp/go-meridian/utc" ) // The package names make intent crystal clear var eastCoastRelease est.Time var westCoastRelease pst.Time var dbStorageTime utc.TimeEach timezone package provides idiomatic helpers:
// Instead of meridian.Now[est.EST]() now := est.Now() // Instead of meridian.Date[pst.PST](2024, 1, 15, 9, 0, 0, 0) releaseTime := pst.Date(2024, 1, 15, 9, 0, 0, 0) // Parse in the timezone's location parsed, _ := est.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 est.Time) errorWrong Code Is Impossible to Compile
Here’s what happens when you try to mix timezones:
func releaseFunds(t est.Time) { // This is always in EST, 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 est.TimeThe compiler catches the bug before it reaches production. To make this work, you must be explicit:
releaseFunds(est.FromMoment(utcTime)) // ✅ Explicit conversion is visibleAll the Methods You Know and Love
Meridian provides the complete time.Time API and supporting factory methods, but type-safe:
func getNextReleaseDay(t est.Time) est.Time { tomorrow := t.Add(24 * time.Hour) // Still est.Time if tomorrow.Weekday() == time.Saturday { return tomorrow.Add(48 * time.Hour) // Still est.Time } if tomorrow.Weekday() == time.Sunday { return tomorrow.Add(24 * time.Hour) // Still est.Time } return tomorrow // Type is preserved throughout }Time arithmetic, formatting, parsing—everything works, and the timezone type is preserved:
scheduledRelease := est.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 est.Time year, month, day := nextRelease.Date() // In ESTSupporting 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 est.Time = est.Date(2024, 12, 25, 14, 0, 0, 0) var westCoastRelease pst.Time = pst.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 := est.Date(2024, 12, 25, 9, 0, 0, 0) fmt.Println("EST:", eastRelease.Format("15:04 MST")) // "09:00 EST" // West coast customer gets funds at the same moment westRelease := pst.FromMoment(eastRelease) fmt.Println("PST:", 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)) // trueFrom standard time.Time:
stdTime := time.Now() // Convert to typed timezones utcTyped := utc.FromMoment(stdTime) estTyped := est.FromMoment(stdTime) pstTyped := pst.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 := est.Now().AddDate(0, 1, 0) midnight := est.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 estTime := est.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:
// est/est.go package est 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 Standard Time timezone. 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 (est.EST, pst.PST, utc.UTC). The type system can distinguish Time[est.EST] from Time[pst.PST], 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 a time.Time field. The generic is erased, giving us type safety without runtime overhead.
-
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 est.Time // Zero value must be valid fmt.Println(release.IsZero()) // Should work without panickingIf 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 est.Time // Zero value: January 1, year 1, 00:00:00 UTC // Can safely call any method without initializationDesign 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 est.Time to work with pst.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 est.Time = est.Date(2024, 12, 25, 14, 0, 0, 0) var westCoastRelease pst.Time = pst.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-meridianThe Meridian source code is available at https://github.com/matthalp/go-meridian.
.png)


