sysl

The time module

sysl.timeInstant and Duration kept apart, the proleptic Gregorian calendar, LocalDate/LocalTime/LocalDateTime, the ISO 8601 renderers and the parsers that read them back.

sysl.time is arithmetic on dates and lengths of time. It is three files and seven types, and almost everything worth saying about it is a distinction the type system holds for you — a point on the timeline is not a length of one, and a reading on a wall clock is neither. Every library that blurs those has the same class of bug in it, and the blur is what the types here refuse.

It requires no capability: every name below is reachable on a target with no operating system.

import sysl.time.*

var launch = date_at(2026, 3, 8)
var meeting = datetime_at(2026, 3, 8, 9, 30, 0)

print(date_text(launch), weekday_name(weekday(launch)))
print(datetime_text(meeting))
print(day_of_year(launch), leap_year(2026))
2026-03-08 Sunday
2026-03-08 09:30
67 false

The two quantities

// A point on the timeline, counted from 1970-01-01T00:00:00Z.
struct Instant
    us: long

// A length of timeline, with no place on it.
struct Duration
    us: long

// How far a zone's wall clock is set from UTC, in whole minutes.
struct Offset
    minutes: int

Both are counts of microseconds, and that is the one representation choice worth arguing about. A long of nanoseconds runs out in 1678–2262 — close enough to today to look fine in every test and fail on a birth date. A long of microseconds reaches ±292,000 years. Nothing here needs to resolve a nanosecond, and a range that quietly ends is a worse defect than a precision that never begins.

Offset is minutes rather than hours because India is at +05:30 and Nepal at +05:45, and a library that assumes whole hours works everywhere its author has lived.

import sysl.time.*

var epoch = Instant(0i64)
var later = epoch + hours(50i64)

print(whole_days(since(later, epoch)), odd_hours(since(later, epoch)))
print(whole_hours(since(epoch, later)))
print(epoch < later, later == epoch + days(2i64) + hours(2i64))
print(whole_hours(-hours(3i64)), whole_hours(hours(3i64) * 4i64))
2 2
-50
true true
-3 12

Which operations exist is the whole design. An instant may be compared with an instant, subtracted from one, and moved by a duration. Two instants may not be added, because the sum of two points on a timeline is not a point on it — and asking for one says so:

import sysl.time.*

var a = Instant(0i64)
var b = Instant(1i64)

print(since(a + b, a))
'+' between sysl.time.Instant and sysl.time.Instant needs 'sysl.Add' — it implements 'sysl.Add[sysl.time.Duration]'

That diagnostic is exact about what went wrong: Instant does implement Add, at Duration, and this call asked for it at Instant.

The difference of two instants is the operator

import sysl.time.*

var a = Instant(0i64)
var b = a + hours(3i64)

print(whole_hours(b - a))
print(whole_hours(a - b))
3
-3

Two rows of Sub sit on Instant, and the type of the right operand is the whole of what selects between them. Subtracting a Duration lands further along the timeline and answers an Instant; subtracting an Instant measures across it and answers a Duration. An operator trait carries its result as Out as well as its operand as Rhs, which is what lets one type do both — the same mechanism Mul uses to give a vector space four products.

This section used to be this module’s report against the language: the most frequently written operation in any date-time library could not be spelled with its own operator, because Sub‘s result was fixed to the type on the left. That is what Out answered, and the gap it describes is closed.

since(later, earlier) is still here and is the same subtraction under a name that says which end is which — later - earlier is right and earlier - later is just as easy to write, as the second line above shows.

Reading a duration back

import sysl.time.*

var d = hours(50i64) + minutes(7i64) + seconds(30i64)

print(whole_days(d), odd_hours(d), odd_minutes(d), odd_seconds(d))
print(whole_hours(d), whole_minutes(d), whole_seconds(d))
print(seconds(90i64) == minutes(1i64) + seconds(30i64))
print(whole_minutes(days(1i64)))
2 2 7 30
50 3007 180450
true
1440

Two families, and the difference between them is the whole reason both exist. whole_hours is the duration in hours — fifty of them. odd_hours is the hours left over after the whole days — two. A caller stating a length to somebody who has to check it wants the first row; a caller formatting one wants the second, and getting them confused is how 2 days, 50 hours gets printed.

Both truncate toward zero, so a negative duration reads as a negative count of each part rather than borrowing across them.

days(1) is a length of exactly 24 hours, and it is not “a day”. A day that a zone’s clocks move through is 23 or 25 hours long, so a calendar day is not a fixed length of timeline at all. That question belongs to the civil types below, which is why plus_days is theirs and not a Duration‘s.

Below the second

millis and micros construct, whole_millis and whole_micros read back. The representation is microseconds, so those are the shortest lengths this module can name — and they are the two an embedded program reaches for, since a blink loop wants 120 milliseconds and seconds(1) is not it.

import sysl.time.*

var d = millis(120i64)

print(whole_millis(d), whole_micros(d), whole_seconds(d))
print(whole_micros(micros(7i64)))
120 120000 0
7

All three numbers on that first row are true of one length: it is a hundred and twenty milliseconds, and it is no seconds at all.

Scaling reads one way round only:

import sysl.time.*

var d = seconds(3i64)

print(whole_seconds(d * 3))
print(whole_seconds(2i64 * d))
'*' needs matching types, got long and sysl.time.Duration

d * 3 is what is written instead, and the count is an ordinary literal — a bare number beside a duration takes the long the one impl Mul names rather than the int a literal with nothing to take would fall back to. An operator’s implementation is written for the type on its left, and nothing may be written for long, so the commutativity a reader expects of multiplication is not something this module could have supplied.

Writing a duration number-first

Every unit is also a property on any integer, so a length can be written the way a datasheet writes one — the number, then the unit:

propertythe constructor it mirrors
5.usmicros(5)
5.msmillis(5)
5.sseconds(5)
5.minutesminutes(5)
5.hourshours(5)
5.daysdays(5)
import sysl.time.*

print(whole_millis(250.ms), whole_micros(250.ms))
print(whole_hours(2.hours + 90.minutes), odd_minutes(2.hours + 90.minutes))
print(whole_millis(250.ms * 4))
250 250000
3 30
1000

The short units are symbols and the long ones are words, which is a split about where each is used rather than a compromise. A timeout, a poll interval and a debounce are the sub-second end, they are written constantly, and ms is the spelling every datasheet already uses — sleep(5.ms) and join(ssid, pw, auth, 20.s) say at a glance what millis(5) and seconds(20) say a moment later. At the other end the number is small and the line is not dense, so 30.days costs nothing and says more than 30.d would.

There is deliberately no 5.min: it would sit beside sysl.math‘s min(a, b) and the type-level int::Min, meaning something different in each position.

The constructors stay, and the two spellings are not a duplication. A duration built from a computed value reads better as millis(n) than as n.millis, and the free functions are what most code that takes a length as an argument is written against.

The properties are one blanket implementation over the whole integer family, so a receiver narrower than the representation widens on the way in rather than overflowing at its own width:

import sysl.time.*

val n: u8 = 200

print(whole_micros(n.ms))
200000

A selective import has to name the trait, which is the one place DurationUnits is written down by anybody using it — a member is reached through the trait that declares it, so importing millis does not bring ms along with it:

import sysl.time.{Duration, DurationUnits, whole_micros}

import sysl.time.* needs nothing said.

There is no matching set for Instant. A point on the timeline has no natural 5.<unit> — five of what, from when? — so naming one still means saying which epoch it is counted from.

The calendar

// Proleptic Gregorian — the Gregorian rules run backwards through the years before anybody
// agreed to them.
struct Civil
    year: int
    month: int
    day: int

// Days since 1970-01-01, signed.
struct LocalDate
    day: int

// Microseconds since midnight.
struct LocalTime
    us: long

struct LocalDateTime
    date: LocalDate
    time: LocalTime

A day number, not packed civil fields. Year, month, day, hour and minute as bit fields inside one word is the design most people reach for, and the arithmetic says no: packing to the nanosecond needs 30 bits for the nanosecond, 17 for the time of day and 9 for the day and month, which is 56 — leaving 8 bits of year, a range of ±128 years, which is not a calendar. A count fits more comfortably at the same width and costs nothing to compute with, because comparing, subtracting and adding a length of time are one instruction each on a count and an unpack-recompute-repack on packed fields. Only “what month is it” wants the fields, and that is the rare question — paid for where it is asked.

Proleptic Gregorian is a decision and not an oversight. The alternative is a calendar with a ten-day hole in it whose position depends on which country you ask.

import sysl.time.*

var d = date_at(2026, 3, 8)

print(year_of(d), month_of(d), day_of(d), month_name(month_of(d)))
print(day_of_year(d), weekday_name(weekday(d)))
print(days_in_month(2026, 2), days_in_month(2024, 2))
print(leap_year(2000), leap_year(1900), leap_year(2024))
print(date_at(1970, 1, 1).day, date_at(1969, 12, 31).day)
print(weekday_name(weekday(date_at(1970, 1, 1))))
2026 3 8 March
67 Sunday
28 29
true false true
0 -1
Thursday

Dates before the epoch are ordinary values rather than a special case. The day number is signed and Hinnant’s civil-from-days algorithm never branches on it — it shifts the epoch to the start of a 400-year era, after which every quantity is non-negative and each leap rule becomes one exact division. days_from_civil and civil_from_days are inverse over the whole int range, which is the property worth asserting rather than any particular date.

The weekday falls out of the same count: 1970-01-01 was a Thursday, so shifting by four puts Sunday at zero. Nothing here needs to know which day a week starts on.

Adding to a calendar

import sysl.time.*

print(date_text(plus_months(date_at(2026, 1, 31), 1)))
print(date_text(plus_months(date_at(2024, 1, 31), 1)))
print(date_text(plus_years(date_at(2024, 2, 29), 1)))
print(date_text(on_or_after(date_at(2026, 3, 8), Tuesday)))
print(datetime_text(plus_days(datetime_at(2026, 3, 8, 9, 30, 0), 7)))
2026-02-28
2024-02-29
2025-02-28
2026-03-10
2026-03-15 09:30

Months are added by the calendar rather than by counting days, and the day is clamped where the target month is shorter. The 31st of January plus one month is the 28th or the 29th of February, whichever that year has. Every library that offers this makes the choice, and clamping is the one that keeps “the last day of the month” landing on a last day. plus_years is plus_months by twelve, which is what makes the 29th of February behave on a non-leap year without a rule of its own.

plus_days on a wall clock reading is not adding a length of time to it. Seven days later is the same clock face seven rows down the calendar, whatever the timeline did in between; across a change of a zone’s clocks the two answers differ by an hour. Both are right — they answer different questions, which is why they are spelled differently. And why this is refused:

import sysl.time.*

var d = date_at(2026, 3, 8)

print(date_text(d + days(1i64)))
'+' needs matching types, got sysl.time.LocalDate and sysl.time.Duration

on_or_after is the first date on or after a given one falling on a given weekday, which is what “every Tuesday” means once a rule has been given a starting point.

Rendering

import sysl.time.*

var t = time_at(9, 30, 7)

print(date_at(2026, 3, 8))
print(time_text(t), time_text(time_at(9, 30, 0)), time_text(LocalTime(500000i64)))
print(datetime_at(2026, 3, 8, 9, 30, 0))
print(offset_text(Offset(330)), offset_text(Offset(-480)), offset_text(Offset(0)))
2026-03-08
09:30:07 09:30 00:00:00.500000
2026-03-08 09:30
+05:30 -08:00 Z

All five types implement Display, so a value goes straight into a print or an interpolation; the *_text functions are the same renderers reached by name, for a caller that wants the string.

Seconds appear only when there are any. A meeting at half past nine is written 09:30 by every human being who has ever written one down, and a renderer insisting on 09:30:00 is reporting its representation rather than its value. The microseconds appear on the same terms — and a renderer that showed a value to the second while dropping what is below it would be reporting something the value does not say.

Z for a zero offset is a spelling rather than a special case: ISO 8601 writes it that way, and parse_offset reads it back as zero.

Reading text back

// Why a parse refused, at the granularity a caller can act on.
enum TimeParseError
    BadShape(at: usize)
    OutOfRange(what: string)
    Trailing(at: usize)
import sysl.time.*

parse_date("2026-03-08") match
    Ok(d) -> print(d)
    Err(e) -> print("refused:", e)

parse_time("09:30") match
    Ok(t) -> print(t)
    Err(e) -> print("refused:", e)

parse_time("09:30:07.5") match
    Ok(t) -> print(t)
    Err(e) -> print("refused:", e)

parse_datetime("2026-03-08T09:30") match
    Ok(x) -> print(x)
    Err(e) -> print("refused:", e)

parse_datetime("2026-03-08 09:30:07") match
    Ok(x) -> print(x)
    Err(e) -> print("refused:", e)

parse_offset("+05:30") match
    Ok(o) -> print(o)
    Err(e) -> print("refused:", e)
2026-03-08
09:30
09:30:07.500000
2026-03-08 09:30
2026-03-08 09:30:07
+05:30

One format, and it is ISO 8601. Not a pattern language, not a locale, not a list of alternatives tried in turn — YYYY-MM-DD, HH:MM[:SS[.ffffff]], and the two joined by a space or a T. That is the format the renderers above produce, so the pair round-trips, and it is the format every machine-readable timestamp in the world is already written in. A pattern language is a bigger feature than this module and belongs above it.

Both join characters are read because both are written: ISO 8601 says T, and datetime_text writes a space because that is what a person reads.

A fraction of fewer than six digits means what it says — .5 is half a second, not five microseconds.

What a parse refuses

import sysl.time.*

parse_date("2026-02-30") match
    Ok(d) -> print(d)
    Err(e) -> print("refused:", e)

parse_date("2026-13-01") match
    Ok(d) -> print(d)
    Err(e) -> print("refused:", e)

parse_date("2026/03/08") match
    Ok(d) -> print(d)
    Err(e) -> print("refused:", e)

parse_date("2026-03-08T") match
    Ok(d) -> print(d)
    Err(e) -> print("refused:", e)

parse_time("24:00") match
    Ok(t) -> print(t)
    Err(e) -> print("refused:", e)
refused: day is out of range
refused: month is out of range
refused: not the expected shape at byte 4
refused: unexpected text at byte 10
refused: hour is out of range

A parse checks the calendar, not only the shape, and the first line is the whole reason to say so. 2026-02-30 is four digits, a dash, two digits, a dash and two digits — it is exactly the right shape and it is not a date. Without holding the day against the month’s real length, the civil conversion accepts it silently and hands back the 2nd of March, which is the failure mode a caller has no way to notice.

The three cases are separated by what a caller would do about them. A shape error is a message about the format; a range error is a message about the value; a trailing error usually means the text held something more that the caller meant to split off first. BadShape and Trailing carry a byte offset for the reason sysl.text‘s BadDigit carries one: a message naming where is worth writing and cannot be reconstructed afterwards.

A fixed offset, which is not a zone

import sysl.time.*

var t = Instant(1772548200000000i64)

print(at_offset(t, Offset(-300)))
print(at_offset(t, Offset(330)))
print(from_offset(at_offset(t, Offset(-300)), Offset(-300)) == t)
print(timestamp_text(t, Offset(-300)))
print(timestamp_text(t, Offset(0)))
print(timestamp_text(Instant(1772548200500000i64), Offset(-300)))
2026-03-03 09:30
2026-03-03 20:00
true
2026-03-03T09:30:00-05:00
2026-03-03T14:30:00Z
2026-03-03T09:30:00.500000-05:00

Both directions are total, and that is the whole difference between an offset and a zone. An offset is a number; a zone is a rule with a history. Ask a zone what instant a wall clock reading names and the honest answers are one, none and two — the hour a spring-forward deletes has no instant in it, and the hour an autumn-back repeats has two. Ask an offset and there is exactly one, always, which is why this pair is a pair of plain functions and a zone conversion could never be.

Most programs that handle timestamps need only this. A timestamp on a wire, in a log line or in a database column already carries the offset that was in force when it was written: the sender resolved the zone, and what arrived is the record of that decision. Reading one back needs no table, no update cadence and no filesystem — which is exactly what makes it the library’s business, where the zone is not.

The renderer is written for machines, and it is the odd one out

timestamp_text breaks two of this page’s own rules, on purpose. The seconds are present even at zero, where datetime_text drops them; the date and time are joined by a T rather than a space; and the offset is flush against the time.

That is RFC 3339, the profile of ISO 8601 that JSON APIs, logs and databases actually agree on, and it requires the seconds. The rule that a renderer should not report what a value does not say is the right rule for a person glancing at a meeting time and exactly the wrong one for a parser with a grammar — so the two renderers are two renderers. The fraction is still omitted when it is zero, because RFC 3339 makes that part optional and .000000 on every timestamp is six characters of noise on the wire.

import sysl.time.*

var t = Instant(1772548200000000i64)

print(parse_timestamp("2026-03-03T09:30:00-05:00").unwrap_or(Instant(0i64)) == t)
print(parse_timestamp("2026-03-03 09:30-05:00").unwrap_or(Instant(0i64)) == t)
print(parse_timestamp("2026-03-03T14:30:00Z").unwrap_or(Instant(0i64)) == t)
print(parse_timestamp("2026-03-03T20:00:00+05:30").unwrap_or(Instant(0i64)) == t)
true
true
true
true

The parser reads more shapes than the renderer writes. It takes the space as well as the T, and a time with no seconds as well as one with them, because what arrives was written by somebody else — while the renderer emits one form, because a wire format with options is a wire format everybody implements differently. Liberal in what it accepts, strict in what it sends.

All four of those lines name the same point on the timeline, which is the point: the offset is not decoration on the reading, it is what turns the reading into an instant.

An offset is not optional

import sysl.time.*

print(str(parse_timestamp("2026-03-03T09:30:00").unwrap_err()))
print(str(parse_timestamp("2026-03-03T09:30:00-25:00").unwrap_err()))
print(str(parse_timestamp("2026-03-03T09:30:00Z ").unwrap_err()))
not the expected shape at byte 19
offset hour is out of range
unexpected text at byte 20

A date and a time with no offset name a wall clock reading, not an instant, and parse_datetime is what reads one of those. Defaulting the missing offset to UTC would be inventing the fact the format exists to carry, and it is the single most common way a timestamp ends up hours wrong in a system that never notices.

Reading a clock — sysl.posix.time

Everything above is arithmetic, and arithmetic needs a value to start from. Obtaining one is asking the environment what time it is, so it is a module of its own — the same split sysl.rand and sysl.posix.rand make, and for the same mechanical reason: a capability requirement is module-wide, so a now() written beside Instant would take the whole calendar away from every freestanding program that only wanted to add two durations.

There are two clocks, and the return types are the distinction rather than a detail.

import sysl.posix.time.{now, monotonic}

// The wall clock: a point on the timeline, comparable with one taken on another machine.
print(now().us > 1577836800000000)

// The monotonic clock: a length of time from an origin nobody specifies.
var t0 = monotonic()
var n: long = 1

for i in 0..<200000
    n = n * 31 + long(i)

var took = monotonic() - t0

print(took.us > 0)
print(n != 0)
true
true
true

now answers an Instant — a point counted from 1970, which is the one to stamp something with. It is also the clock a person and an ntpd are both allowed to set, so two readings of it can differ by anything at all, including a negative amount.

monotonic answers a Duration, not an Instant, and that is the whole of the design. It is counted from an origin the module deliberately does not name — boot, usually — so a single reading means nothing and only the difference of two does. It cannot be set and never goes backwards, which is what makes it the one to measure with. Giving it a type that no calendar function accepts is what stops a measurement being mistaken for a timestamp:

import sysl.posix.time.monotonic
import sysl.time.instant_text

print(instant_text(monotonic()))
error: 't' of 'sysl.time.instant_text' is sysl.time.Instant, but sysl.time.Duration was given

Both require posix.

What a freestanding target does instead

Nothing in the library, and deliberately nothing shared with it. A board’s clock is a board’s decision: two boards carrying the same chip — so the same target — can count time from a different RTC, which is precisely the case a compile-time switch on the target cannot express. So an embedded environment supplies a module of its own with these two function names, in its own package, and a program picks its clock by which one it imports.

What that gives up, for now, is a program that compiles unchanged against both a host clock and a board’s. The shape that would buy it is a symbol declared in capability-free sysl.time that a host module and a board package alike link an implementation of — sysl.harness‘s attach one layer down — and it is not built, for want of a second thing that needs it. The names above are chosen so that adding it later moves no caller.

What is not here

The zone, as distinct from the offset above. A wall clock reading becomes an instant only once somebody says where the wall is, and answering that from a nameAmerica/New_York rather than -05:00 — needs the IANA time zone database — a table that changes several times a year, which a standard library either ships and lets go stale or reads from the host and thereby needs a filesystem. Both are decisions with costs, and neither belongs in a module whose whole claim is that it is arithmetic. The date and time guide builds a fixed-offset zone over this module and shows what the real thing would take.

wall_us and wall_of are the seam a zone conversion starts from: they read a LocalDateTime as a single count measured from the same origin as an Instant, which is what the count would be if the offset happened to be zero.

The clock, in this module. There is no sysl.time.now(), because reading one is a capability rather than arithmetic — clock_gettime is a call into the environment, and this module has no requires line. An Instant is a value a freestanding target can compute with; where it comes from is the caller’s to say, and on a host the answer is sysl.posix.time above.


Next: sysl.sync — atomics and the spinlock, which require nothing at all.

Search

Esc
to navigate to open Esc to close