DocsStandard library
Dates and times.
How Fango models calendar dates, moments, zones, and the current time, and which type to reach for.
Dates and times in Fango are plain immutable values: a date is a date, and adding a month gives you a new one. Only one thing depends on the outside world, the current time, and you get it through an effect.
API reference: Time · Time.Clock · Time.Date · Time.DateTime · Time.Duration · Time.Period · Time.Zone
Reading the current time.
Most programs start here:
import Time.Clock as Clock
import Time.Date as Date
import Time.DateTime as DateTime
main() =
print (DateTime.toIso Clock.now())
print (Date.toIso Clock.today())
This prints something like 2026-10-07T09:30:00.25-03:00 followed by 2026-10-07.
Clock.now() and Clock.today() are operations of the Clock effect. They work only inside a handler that answers them. Clock.runSystem reads the system clock and uses the machine’s timezone, and it’s the clock’s default handler, so main gets it without asking. Anywhere else you choose the handler, the way you do for any other effect. There’s no hidden global clock. If a function’s type doesn’t mention Clock, it can’t ask for the time.
That pays off in tests. Code that depends on “now” can run under Clock.runFixed, which answers every reading with the moment you give it:
import Fail
import Result exposing (Result(..))
import Time.Clock as Clock exposing (Clock)
import Time.DateTime as DateTime
greeting : () ->{Clock} String
greeting() =
if DateTime.hour Clock.now() < 12 then "Good morning" else "Good afternoon"
main() =
case Fail.attempt { DateTime.parse "2026-10-07T09:30:00-03:00" } of
Ok moment -> print (Clock.runFixed moment greeting)
Err error -> print error
Good morning
greeting doesn’t know which handler is running. In production it gets the real time, and in a test it gets 9:30 every time.
Advancing a test clock.
Sometimes a test needs time to move. Clock.runStepped returns the starting moment on its first reading, then advances by a duration for each reading after that:
import Fail
import Result exposing (Result(..))
import Time.Clock as Clock
import Time.DateTime as DateTime
import Time.Duration as Duration
main() =
result = Fail.attempt {
start = DateTime.parse "2026-01-31T23:59:59Z"
Clock.runStepped start (Duration.seconds 1) {
first = Clock.now()
second = Clock.now()
(DateTime.toIso first, DateTime.toIso second)
}
}
print result
Ok ("2026-01-31T23:59:59Z", "2026-02-01T00:00:00Z")
Each now, nowIn, today, or todayIn uses up one reading. Asking for the zone doesn’t. The step is elapsed time, so zero freezes the clock and a negative duration runs it backwards. Nested runners each keep their own sequence.
Tasks you spawn share their parent’s clock, so readings taken in parallel tasks come out in whatever order the tasks run. For a deterministic test, take the readings in sequence.
Clock.runOffset start action starts from the moment you choose and then advances with real elapsed time. It performs IO like runSystem, and because it measures from when the runner started, adjustments to the system clock don’t affect it. Its first reading may already be a little later than start.
Both runners use the starting value’s zone. A reading outside the supported range is a runtime fault, not a Fail result.
The testing guide shows how to choose handlers inside a test. For a small program that uses a fixed clock, try fixed-clock billing in the playground.
A type for each question.
A lot of date bugs come from treating different questions as the same one. “Which day is the invoice due?” is a question about the calendar. “How long did the request take?” is about elapsed time. “When did this happen, and what did the clock on the wall say?” needs a timezone. Fango gives each of these its own type, so the compiler keeps them apart:
| Type | What it is | Example |
|---|---|---|
Date |
A calendar day, with no time or zone | a birthday, a due date |
TimeOfDay |
A wall-clock reading, with no date or zone | opening hours |
Instant |
A point on the global timeline | a log timestamp |
DateTime |
An instant seen in a particular zone | a meeting in São Paulo |
Duration |
An exact amount of elapsed time | a timeout of 90 seconds |
Period |
A calendar adjustment, in months and days | “one month later” |
Zone |
The offset rules of a timezone | America/New_York |
You usually move between them in one direction. A DateTime has a date and a timeOfDay, and toInstant drops its zone. Going the other way you have to say what’s missing: a Date needs a time and a zone before it becomes a moment.
Calendar time and elapsed time.
This is the main idea in the library: a month isn’t a fixed number of seconds, and a day isn’t always 24 hours. So there are two kinds of amounts and two ways to move:
- A
Periodmoves calendar fields. Adding a month to January 31 gives February 28, because the day clamps to the end of the shorter month. - A
Durationcounts exact elapsed time, down to the nanosecond. Adding 90 minutes adds 90 minutes.
import Fail
import Result exposing (Result(..))
import Time.Date as Date
import Time.DateTime as DateTime
import Time.Duration as Duration
main() =
result = Fail.attempt {
due = Date.make 2026 1 31
print (Date.toIso (Date.addMonths 1 due))
print (Date.daysBetween due (Date.make 2026 3 1))
start = DateTime.parse "2026-10-07T09:00:00-03:00"
finish = DateTime.add (Duration.minutes 90) start
print (DateTime.toIso finish)
print (Duration.toMinutes (DateTime.between start finish))
}
case result of
Ok () -> ()
Err error -> print error
2026-02-28
29
2026-10-07T10:30:00-03:00
90
On a DateTime you choose which kind of movement you want. advance and helpers like addDays and addMonths move the local calendar, while add moves along the timeline. Usually the two agree. They don’t when the zone changes its offset in between, as shown in the next section.
Calendar boundaries work the same way. beginningOfMonth, endOfQuarter and similar functions round a date, and monthRange and its relatives return a half-open window, from the first moment of the month up to the first moment of the next. Checking start <= moment && moment < finish never double-counts a boundary.
Zones are explicit.
A DateTime always carries its zone, and nothing quietly picks one for you. Zone.utc and Zone.fixedOffset are pure values. Zone.load "Europe/Lisbon" loads rules from the timezone database, so it needs IO and returns Nothing for an unknown name. The machine’s own zone comes from Zone.system(), and only when you ask for it.
Two DateTime values are equal when they’re the same moment, even if they’re viewed in different zones. 09:00 in São Paulo equals 12:00 UTC. sameRepresentation also checks the zone, for the cases where that matters. inZone views the same moment in another zone, and it can’t fail.
Zones with daylight saving time are where calendar and elapsed movement differ. In New York, clocks jump forward on the night of March 8, 2026:
import Fail
import Maybe exposing (Maybe(..))
import Result exposing (Result(..))
import Time.DateTime as DateTime
import Time.Duration as Duration
import Time.Zone as Zone
main() =
case Zone.load "America/New_York" of
Nothing -> print "zone not found"
Just newYork ->
result = Fail.attempt {
evening = DateTime.parse "2026-03-07T20:00:00-05:00" |> DateTime.inZone newYork
print (DateTime.toIso (DateTime.addDays 1 evening))
print (DateTime.toIso (DateTime.add (Duration.hours 24) evening))
}
case result of
Ok () -> ()
Err error -> print error
2026-03-08T20:00:00-04:00
2026-03-08T21:00:00-04:00
“The same time tomorrow” is 8 p.m. again, which is only 23 hours later. Exactly 24 hours later is 9 p.m.
The same transitions make some local readings ambiguous. 2:30 a.m. never happens on the night clocks jump forward, and 1:30 a.m. happens twice on the night they fall back. By default Fango picks the earlier of two readings and moves past a gap. Functions ending in With, such as makeWith, take a Resolution when you need a different choice, including Reject to fail instead.
ISO text keeps the offset (-03:00) but not the zone’s name, so parsing it back gives the same moment in a fixed-offset zone. If you need the zone to survive storage, store its identifier separately.
What can fail.
Most of the library can’t fail. Viewing a moment in a zone, formatting, comparing, and arithmetic on durations and periods always produce a value. Failure is limited to a few places:
- building a value from parts or text that don’t make sense, such as
Date.make 2026 2 30or a malformed timestamp; - moving outside the supported calendar, which runs from year 1 to year 9999;
- a local reading that a zone skips or repeats, when you’ve asked for
Reject.
These all use the Fail effect with one shared error type, Time.Error. Run under Fail.attempt, Date.make 2026 2 30 gives Err (InvalidComponent "day"). Because every module uses the same error type, a single Fail.attempt can wrap a block that mixes dates, zones and clocks.
Where to go next.
The API reference documents every function and lists the errors each one can raise:
- Time.Date and Time.TimeOfDay: calendar days and wall-clock readings, with parsing and
format. - Time.DateTime and Time.Instant: moments, with zone conversion, movement and ranges.
- Time.Duration and Time.Period: elapsed time and calendar adjustments.
- Time.Zone: UTC, fixed offsets and named zones.
- Time.Clock: the current time, and handlers for production and tests.
Some things aren’t included yet: recurring events, holiday calendars, localized formatting, parsing with custom patterns, and a stopwatch for measuring performance. The system clock can jump when it is adjusted, so don’t use it to time code.
