Audience: Developers who want date/time support in an ExpresZo parser.
The core Expreszo package ships with no date/time functions. Date support lives
in a separate, optional NuGet package — Expreszo.DateTime —
that registers 76 functions on a parser when you opt in. It is a faithful
port of the TypeScript @pro-fa/expreszo-datetime
plugin, backed by the BCL (DateTimeOffset + TimeZoneInfo) with no external
dependencies, and is Native AOT and trim compatible like the core.
dotnet add package Expreszo.DateTimeExpreszo is brought in transitively, so you don't need to install it
separately (though it's harmless to be explicit). Targets net10.0.
Register the plugin when you build the parser with the Parser.WithPlugins
factory:
using Expreszo;
using Expreszo.DateTimes;
var parser = Parser.WithPlugins([new ExpreszoDateTimePlugin()]);
parser.Evaluate("format(addDuration('2026-01-01', 7, 'days'), 'yyyy-MM-dd')");
// => Value.String "2026-01-08"!!! note "Namespace vs. package id"
The NuGet package and assembly are named Expreszo.DateTime (singular),
but the code namespace is Expreszo.DateTimes (plural). A namespace
ending in DateTime would shadow System.DateTime, so the plural form is
used in code while the package keeps the singular, familiar name.
Equivalently, you can pass plugins through ParserOptions — useful when you are
already constructing options:
var parser = new Parser(new ParserOptions
{
Plugins = [new ExpreszoDateTimePlugin()],
});A Parser is immutable and thread-safe once built; construct it once and reuse
it across threads and evaluations.
The same surface is public, so any package can contribute functions:
public sealed class MyPlugin : IExpreszoPlugin
{
public string Name => "my-company/my-plugin";
public string Version => "1.0.0";
public void Register(IPluginRegistration r) =>
r.AddFunction("double", (args, _) =>
ValueTask.FromResult<Value>(Value.Number.Of(((Value.Number)args[0]).V * 2)));
}Registration is explicit (no reflection), which keeps plugin wiring AOT- and trim-safe.
Impure functions (now, today, age, the distance and relative-to-now
helpers) read a clock, and zone-less values are interpreted in a "local" zone.
Both are configurable via DateTimeOptions — most useful for deterministic
tests and for pinning a server zone:
var plugin = new ExpreszoDateTimePlugin(new DateTimeOptions
{
NowProvider = () => DateTimeOffset.Parse("2026-04-15T12:00:00Z"), // fixed clock
DefaultZone = TimeZoneInfo.Utc, // "local" zone
});
var parser = Parser.WithPlugins([plugin]);
parser.Evaluate("daysUntil('2026-04-20T12:00:00Z')"); // => 5| Option | Default | Purpose |
|---|---|---|
NowProvider |
() => DateTimeOffset.Now |
Source of "now" for impure functions. |
DefaultZone |
TimeZoneInfo.Local |
Zone for now/today/time, parsers/constructors with no explicit zone, and inspector rendering. |
A date argument accepts any of these shapes, normalised at the boundary:
| Shape | Example in an expression |
|---|---|
A Value.DateTime (produced by another datetime function) |
addDuration(now(), 1, 'days') |
| ISO 8601 string | '2026-01-01T00:00:00Z' |
| Unix millisecond number | 1767225600000 |
Native System.DateTime / DateTimeOffset (via a variable) |
year(d) with d bound to a CLR date |
Functions that produce a date return a Value.DateTime, so chains stay
efficient. Functions that produce text (format, toISO, toRelative) or
numbers (year, diff, toMillis) return those directly.
JsonDocument input can't carry a native date, so native
System.DateTime/DateTimeOffset values enter through the variable resolver.
The package ships a helper that builds one:
using Expreszo.DateTimes;
var vars = DateTimeVariables.FromObjects(
new Dictionary<string, object?> { ["d"] = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) });
parser.Evaluate("format(addDuration(d, 7, 'days'), 'yyyy-MM-dd')", values: null, resolver: vars);
// => "2026-01-08"FromObjects maps DateTimeOffset/DateTime to Value.DateTime and other
scalars (string, bool, numeric) to their natural value kinds. For ad-hoc
conversion outside an expression, Normalize.ToDateTime /
Normalize.ToDateTimeOrUndefined are public too.
DateTime values flow through the core comparison operators, comparing by instant
(in Unix milliseconds, matching the TypeScript valueOf semantics):
parseISO('2026-01-01') == parseISO('2026-01-01') // true
parseISO('2026-01-01') < parseISO('2026-02-01') // true
parseISO('2026-01-01') >= parseISO('2026-01-01') // true
A date also coerces to its Unix-millisecond value in numeric contexts
(x as 'number', mixed </> with a number). Arithmetic operators (+, -)
are intentionally not date-aware — use addDuration, subtractDuration,
and diff, which are explicit about units.
A date result serializes to an ISO 8601 string (JSON has no native date type):
using Expreszo.Json;
Value r = parser.Evaluate("parseISO('2026-01-01T00:00:00Z')");
JsonBridge.ToJsonString(r); // => "\"2026-01-01T00:00:00.000+00:00\""Units accepted by addDuration/subtractDuration/startOf/endOf/diff/dateRange
are: year(s), quarter(s), month(s), week(s), day(s), hour(s),
minute(s), second(s), millisecond(s) (singular or plural).
| Function | Returns | Notes |
|---|---|---|
now() |
DateTime | Current instant. |
today() |
DateTime | Start of the current day. |
yesterday() / tomorrow() |
DateTime | Start of the day before / after. |
parseISO(iso) |
DateTime | Parse an ISO 8601 string. |
parseDate(input, format, zone?) |
DateTime | Parse with a Luxon-style format token; optional IANA zone. |
fromMillis(ms) / fromUnix(seconds) |
DateTime | From a Unix timestamp. |
dateTime(y, m, d, h?, mi?, s?) |
DateTime | From numeric components. |
date(y, m, d) |
DateTime | Midnight from year/month/day. |
time(h, mi, s?, ms?) |
DateTime | Today at the given clock time. |
year, month, day, hour, minute, second, millisecond,
dayOfWeek (1 = Mon … 7 = Sun), dayOfYear, weekOfYear (ISO),
daysInMonth, quarter (1–4), isoWeekYear, isLeapYear, daysInYear,
weeksInYear, isDST, offsetMinutes, offsetHours, zoneName,
isWeekend, isWeekday, isValid.
isToday, isYesterday, isTomorrow, isThisWeek, isThisMonth,
isThisYear, isInPast, isInFuture, age (whole years; 0 for future dates).
| Function | Returns | Notes |
|---|---|---|
addDuration(d, amount, unit) |
DateTime | |
subtractDuration(d, amount, unit) |
DateTime | |
startOf(d, unit) / endOf(d, unit) |
DateTime | Truncate to the unit boundary. |
diff(d1, d2, unit) |
number | d1 - d2 in the unit. |
clampDate(d, low, high) |
DateTime | Clamp into [low, high]. |
minDate(...) / maxDate(...) |
DateTime | Earliest / latest of N dates. |
| Function | Returns | Notes |
|---|---|---|
isBefore(d1, d2) / isAfter(d1, d2) |
boolean | |
isSame(d1, d2, unit?) |
boolean | Exact equality, or truncated to a unit. |
isBetween(d, start, end, inclusive?) |
boolean | inclusive defaults to true. |
compareDates(d1, d2) |
number | -1 / 0 / 1 — usable as a sort key. |
overlapsRange(s1, e1, s2, e2) |
boolean | |
containsDate(start, end, d) |
boolean |
| Function | Returns | Notes |
|---|---|---|
dateRange(start, end, unit, step?) |
array of DateTime | Half-open [start, end); step defaults to 1. |
businessDaysBetween(start, end) |
number | Count of Mon–Fri in [start, end). |
weekdaysBetween(start, end, weekday) |
number | Count of a given weekday (1–7) in [start, end). |
daysUntil, daysSince, hoursUntil, hoursSince, minutesUntil,
minutesSince — whole units, truncated toward zero.
| Function | Returns | Notes |
|---|---|---|
format(d, pattern) |
string | Luxon-style format tokens (yyyy-MM-dd, MMM d, yyyy, …). |
toISO(d) |
string | ISO 8601. |
toMillis(d) / toUnix(d) |
number | Unix milliseconds / seconds. |
setZone(d, zone) |
DateTime | Re-zone ('utc', 'local', or an IANA id). |
toUTC(d) / toLocal(d) |
DateTime | Sugar for setZone(d, 'utc' | 'local'). |
toRelative(d, base?) |
string | e.g. "in 5 days". |
toRelativeCalendar(d, base?) |
string | e.g. "tomorrow". |
- Default zone: in production
DefaultZoneisTimeZoneInfo.Local. Pin it toTimeZoneInfo.Utc(and a fixedNowProvider) in tests for determinism. - IANA zone ids (
'America/New_York') require ICU, which is the default on .NET 6+ across Windows/Linux/macOS. - Fractional calendar amounts (e.g.
0.5months) are rejected; use whole amounts foryear/quarter/monthunits. toRelative/toRelativeCalendarproduce English strings and are not locale-aware.diffformonth/quarter/yearuses a calendar-fractional algorithm.