Every application deals with time. Deadlines. Expiration dates. Cache durations. Subscription periods. And every application reinvents the wheel: importing a date library, parsing format strings, handling timezone edge cases, converting between units.
FLIN's time arithmetic, implemented in Session 078, takes a different approach. Duration literals are first-class syntax. Time operations are type-checked at compile time. And thanks to constant folding, duration literals compile to plain integers with zero runtime overhead.
The result is that deadline = now + 7.days is not a library call -- it is a language expression that the compiler verifies and optimizes.
The Syntax: Durations as Member Access
FLIN's duration syntax piggybacks on member access. A number followed by a time unit creates a duration:
flintimeout = 30.seconds
cache_ttl = 5.minutes
sprint = 14.days
subscription = 1.months
annual = 1.yearsSeven units are supported:
| Unit | Milliseconds |
|---|---|
.seconds | 1,000 |
.minutes | 60,000 |
.hours | 3,600,000 |
.days | 86,400,000 |
.weeks | 604,800,000 |
.months | 2,592,000,000 (30 days) |
.years | 31,536,000,000 (365 days) |
Floating-point values work too:
flinhalf_hour = 0.5.hours // 1,800,000 ms
ninety_mins = 1.5.hours // 5,400,000 msThe syntax was chosen deliberately. We could have used function calls (days(7)) or operator overloading (7 * DAY). But 7.days reads like English, requires no imports, and fits FLIN's philosophy of natural expression. It also required no lexer changes -- the parser recognizes it as member access on a numeric literal and converts it to a duration expression.
Time Arithmetic: Type-Safe Operations
Durations compose with time values through arithmetic operators. The type checker enforces that only valid combinations are allowed:
flin// Time + Duration = Time
deadline = now + 7.days
reminder = event_time - 1.hours
// Time - Time = Duration
time_remaining = deadline - now
// Duration + Duration = Duration
total = 2.hours + 30.minutes
// Duration - Duration = Duration
remaining = 1.hours - 15.minutes
// Duration * Number = Duration
double_timeout = 30.seconds * 2
// Duration / Number = Duration
half_period = 1.hours / 2The type rules are explicit and exhaustive:
Time + Duration --> Time
Time - Duration --> Time
Time - Time --> Duration
Duration + Duration --> Duration
Duration - Duration --> Duration
Duration * Int/Float --> Duration
Duration / Int/Float --> DurationAny other combination is a compile-time error:
flin// Compile error: cannot add Time + Time
bad = now + now
// Compile error: cannot add Duration + Int
also_bad = 7.days + 42
// Compile error: cannot multiply Time * Duration
very_bad = now * 7.daysThese errors are caught by the type checker before any code is generated. The developer gets a clear error message at compile time, not a cryptic runtime exception.
Implementation: Seven Units, Zero New Opcodes
The elegance of the implementation lies in what we did not build. Duration arithmetic requires no new VM opcodes. Durations are represented as plain integers (i64 milliseconds) at runtime, and arithmetic uses the existing Add, Sub, Mul, and Div opcodes.
The magic happens at two layers: the type system (which enforces valid combinations) and the code generator (which converts duration literals to integer constants).
AST Extension
A new DurationUnit enum and Expr::Duration variant were added to the AST:
rustpub enum DurationUnit {
Seconds,
Minutes,
Hours,
Days,
Weeks,
Months,
Years,
}
// In the Expr enum
Expr::Duration {
value: Box<Expr>,
unit: DurationUnit,
span: Span,
}Parser Integration
The parser recognizes duration units inside member access. When parsing 30.seconds, it first parses 30 as a numeric literal, then encounters .seconds. Instead of treating this as a field access on an integer (which would be a type error), it recognizes seconds as a duration unit and constructs an Expr::Duration node.
rust// In member access parsing
if let Some(unit) = match name.as_str() {
"seconds" => Some(DurationUnit::Seconds),
"minutes" => Some(DurationUnit::Minutes),
"hours" => Some(DurationUnit::Hours),
"days" => Some(DurationUnit::Days),
"weeks" => Some(DurationUnit::Weeks),
"months" => Some(DurationUnit::Months),
"years" => Some(DurationUnit::Years),
_ => None,
} {
return Ok(Expr::Duration { value: Box::new(expr), unit, span });
}Nine lines of parser code. No lexer changes. The duration syntax is handled entirely within the existing member access parsing logic.
Type System
A new FlinType::Duration variant was added. The type checker validates that the value inside a duration expression is numeric (Int or Float) and that arithmetic operations involving Duration follow the rules above.
rust// Duration literal type checking
Expr::Duration { value, unit, .. } => {
let value_ty = self.check_expr(value)?;
match value_ty {
FlinType::Int | FlinType::Float => Ok(FlinType::Duration),
other => Err(TypeError::new(
format!("Duration value must be numeric, found {}", other),
span,
)),
}
}The binary operation type checking was extended with duration-aware rules:
rust// Time arithmetic rules in the type checker
(FlinType::Time, BinOp::Add, FlinType::Duration) => Ok(FlinType::Time),
(FlinType::Time, BinOp::Sub, FlinType::Duration) => Ok(FlinType::Time),
(FlinType::Time, BinOp::Sub, FlinType::Time) => Ok(FlinType::Duration),
(FlinType::Duration, BinOp::Add, FlinType::Duration) => Ok(FlinType::Duration),
(FlinType::Duration, BinOp::Sub, FlinType::Duration) => Ok(FlinType::Duration),
(FlinType::Duration, BinOp::Mul, FlinType::Int | FlinType::Float) => Ok(FlinType::Duration),
(FlinType::Duration, BinOp::Div, FlinType::Int | FlinType::Float) => Ok(FlinType::Duration),Code Generation: Constant Folding
The code generator converts duration literals to integer constants at compile time. 30.seconds does not generate a multiplication instruction -- it generates a single PushConst(30000).
rustfn emit_duration(&mut self, value: &Expr, unit: &DurationUnit) -> EmitResult {
let multiplier: i64 = match unit {
DurationUnit::Seconds => 1_000,
DurationUnit::Minutes => 60_000,
DurationUnit::Hours => 3_600_000,
DurationUnit::Days => 86_400_000,
DurationUnit::Weeks => 604_800_000,
DurationUnit::Months => 2_592_000_000,
DurationUnit::Years => 31_536_000_000,
};
// Constant folding for literal values
if let Expr::IntLiteral(n, _) = value {
let ms = n * multiplier;
self.emit_push_const(Value::Int(ms));
return Ok(());
}
if let Expr::FloatLiteral(f, _) = value {
let ms = (f * multiplier as f64) as i64;
self.emit_push_const(Value::Int(ms));
return Ok(());
}
// Non-constant: emit value, then multiply
self.emit_expr(value)?;
self.emit_push_const(Value::Int(multiplier));
self.emit_opcode(OpCode::Mul);
Ok(())
}For literal values (the common case), the multiplication happens at compile time and the result is embedded directly in the bytecode. For computed values (like n.days where n is a variable), the emitter generates a runtime multiplication against the unit's millisecond constant.
This is a zero-cost abstraction in the truest sense: 30.seconds and 30000 generate identical bytecode.
Why Milliseconds?
We chose milliseconds as the internal representation for several reasons:
Precision. Milliseconds are precise enough for application-level timing (sub-second deadlines, animation durations) without the complexity of nanoseconds or microseconds.
Compatibility. JavaScript's Date.now() returns milliseconds. Most web APIs use milliseconds. FLIN applications that interact with web APIs can pass timestamps directly without conversion.
Integer arithmetic. By using i64 milliseconds, all time operations are integer arithmetic -- fast, deterministic, and free of floating-point precision issues. The maximum representable duration is approximately 292 million years, which should suffice for most applications.
Simplicity. One representation, one unit, no conversion tables. A duration is a number. Time arithmetic is integer addition. The VM does not need a special "duration type" at runtime.
Interaction with Temporal Keywords
Time arithmetic composes naturally with FLIN's temporal keywords:
flin// Deadline is 7 days from now
deadline = now + 7.days
// Reminder is 1 hour before the deadline
reminder_time = deadline - 1.hours
// How much time is left?
time_remaining = deadline - now
// Entity query: users created in the last 90 days
recent_users = User.where(created_at > now - 90.days)
// Cache control
cache_until = now + 5.minutes
is_cached = now < cache_untilThe temporal keywords (now, today, yesterday, etc.) return time values. Duration literals return duration values. The type checker ensures that only valid combinations are used. The result is a complete time manipulation system that reads like English and compiles to integer operations.
Design Decisions
Why N.days Instead of days(N)?
Function call syntax (days(7)) would have required registering seven built-in functions. Member access syntax (7.days) required nine lines of parser code and zero new functions. The syntax also reads more naturally -- "seven days" versus "days of seven."
Why Approximate Months and Years?
Months are thirty days. Years are three hundred sixty-five days. These are approximations -- real months vary from twenty-eight to thirty-one days, and leap years have three hundred sixty-six. We chose approximate values because:
- Exact calendar arithmetic requires knowing the starting date, the calendar system, and timezone rules. This complexity belongs in a date library, not a language primitive.
- For the use cases FLIN targets (cache durations, subscription periods, retention policies), approximate durations are sufficient. "Retain data for ninety days" does not need to account for February's length.
- Developers who need exact calendar arithmetic can use explicit date manipulation functions (planned for future FLIN releases).
Why Type Safety Instead of Runtime Checks?
We could have represented durations as integers at the type level (just like they are at runtime) and let developers add Time + Int freely. The type safety would be lost, but the implementation would be simpler.
We chose type safety because wrong time arithmetic is a common bug category. Adding a raw integer to a timestamp produces a result in the wrong unit (seconds versus milliseconds, or worse). The type system catches these errors at compile time, before they cause midnight-deployed-as-noon bugs in production.
Common Patterns Enabled by Time Arithmetic
Time arithmetic unlocks a category of application logic that was previously awkward to express:
Subscription management:
flinentity Subscription {
user: User
plan: text
started_at: time
expires_at: time
}
sub = Subscription {
user: current_user,
plan: "monthly",
started_at: now,
expires_at: now + 30.days
}
save sub
is_expired = now > sub.expires_at
days_left = (sub.expires_at - now) / 1.daysSession timeouts:
flinsession_timeout = 30.minutes
last_activity = user.updated_at
is_timed_out = now > last_activity + session_timeoutScheduled tasks:
flinentity Task {
name: text
due_at: time
remind_at: time
}
task = Task {
name: "Submit report",
due_at: now + 7.days,
remind_at: now + 6.days // Remind 1 day before
}
save taskEach pattern reads naturally. No date library imports. No format string parsing. No timezone conversion utilities. Just arithmetic on time values.
Testing
The example file time-arithmetic-test.flin exercises every combination:
flin// Duration literals
timeout = 30.seconds
cache_ttl = 5.minutes
sprint = 14.days
subscription = 1.months
// Floating-point durations
half_hour = 0.5.hours
ninety_mins = 1.5.hours
// Time arithmetic
deadline = now + 7.days
reminder_time = deadline - 1.hours
time_left = deadline - now
// Duration operations
total_time = 2.hours + 30.minutes
remaining = 1.hours - 15.minutes
double_timeout = 30.seconds * 2All expressions type-check successfully. All one thousand and ten library tests pass. The implementation adds zero runtime overhead for literal durations thanks to constant folding.
Progress Impact
Session 078 completed all twelve TEMP-5 tasks in a single session, bringing Time Arithmetic from zero percent to one hundred percent:
- AST extension (DurationUnit enum, Expr::Duration)
- Parser integration (member access recognition)
- Type system (FlinType::Duration)
- Type checking (duration literal validation, time arithmetic rules)
- Code generation (emit_duration with constant folding)
- No new VM opcodes needed
- Testing and validation
Overall temporal progress moved from seventy-one to eighty-three out of one hundred sixty tasks (fifty-one point nine percent). FLIN's temporal model crossed the halfway mark.
One hundred fifty-three lines of new code. Seven duration units. Type-safe time arithmetic. Zero runtime overhead. And deadline = now + 7.days became the most natural expression in the language.
This is Part 8 of the "How We Built FLIN" temporal model series, documenting the time arithmetic system that makes date operations feel like native language expressions.
Series Navigation: - [046] Every Entity Remembers Everything: The Temporal Model - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates (you are here) - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has