Skip to content

Temporal API

Roger Johansson edited this page Jan 14, 2026 · 1 revision

Temporal API

The engine implements the TC39 Temporal proposal - a modern date/time API with nanosecond precision and proper timezone support.


Overview

flowchart TB
    subgraph Temporal["Temporal Namespace"]
        Now((Temporal.Now))
        Instant((Instant))
        Duration((Duration))
        PlainDate((PlainDate))
        PlainTime((PlainTime))
        PlainDateTime((PlainDateTime))
        ZonedDateTime((ZonedDateTime))
        PlainYearMonth((PlainYearMonth))
        PlainMonthDay((PlainMonthDay))
    end
    
    Now --> Instant
    Now --> PlainDate
    Now --> PlainTime
    Now --> PlainDateTime
    Now --> ZonedDateTime
    
    PlainDate --> PlainDateTime
    PlainTime --> PlainDateTime
    PlainDateTime --> ZonedDateTime
    Instant --> ZonedDateTime
Loading

Key Files

File Purpose
StdLib/Temporal/TemporalHelper.cs Temporal namespace and constructors
JsTypes/JsTemporalInstant.cs Exact moment in time (nanoseconds)
JsTypes/JsTemporalDuration.cs Time span representation
JsTypes/JsTemporalPlainDate.cs Calendar date without time
JsTypes/JsTemporalPlainTime.cs Wall-clock time without date
JsTypes/JsTemporalPlainDateTime.cs Combined date and time
JsTypes/JsTemporalZonedDateTime.cs Date/time with timezone
JsTypes/JsTemporalPlainYearMonth.cs Year and month only
JsTypes/JsTemporalPlainMonthDay.cs Month and day only

Nanosecond Precision

The Challenge

JavaScript Date only has millisecond precision. Temporal requires nanosecond precision (10^-9 seconds).

Solution: BigInteger

public sealed class JsTemporalInstant(BigInteger epochNanoseconds)
    : IEquatable<JsTemporalInstant>, IComparable<JsTemporalInstant>
{
    private const long NanosecondsPerMillisecond = 1_000_000L;
    private const long NanosecondsPerSecond = 1_000_000_000L;

    public BigInteger EpochNanoseconds { get; } = epochNanoseconds;
    
    public long EpochMilliseconds => (long)(EpochNanoseconds / NanosecondsPerMillisecond);
    public long EpochSeconds => (long)(EpochNanoseconds / NanosecondsPerSecond);
}

.NET Interop

flowchart LR
    subgraph Temporal["Temporal (nanoseconds)"]
        BigInt["BigInteger\n(nanoseconds)"]
    end
    
    subgraph DotNet[".NET"]
        DTO["DateTimeOffset\n(100ns ticks)"]
    end
    
    BigInt -->|"/ 100"| DTO
    DTO -->|"* 100"| BigInt
Loading
private static BigInteger DateTimeOffsetToNanoseconds(DateTimeOffset dto)
{
    var ticks = (dto - UnixEpoch).Ticks;
    // 1 tick = 100 nanoseconds
    return new BigInteger(ticks) * 100;
}

public DateTimeOffset ToDateTimeOffset()
{
    // Convert nanoseconds to ticks (1 tick = 100ns)
    // Note: Loses precision beyond 100ns
    var ticks = (long)(EpochNanoseconds / 100);
    return UnixEpoch.AddTicks(ticks);
}

Temporal.Now

Provides current time in various formats:

private static JsObject CreateTemporalNow(RealmState realm, TemporalPrototypes prototypes)
{
    var now = new JsObject(realm.ObjectPrototype);

    // Temporal.Now.instant()
    now.DefineProperty("instant", CreateFunction(realm, "instant", 0, (_, _) =>
    {
        var instant = JsTemporalInstant.Now();
        return WrapInstant(instant, realm, prototypes.InstantPrototype);
    }));

    // Temporal.Now.timeZoneId()
    now.DefineProperty("timeZoneId", CreateFunction(realm, "timeZoneId", 0, (_, _) =>
    {
        var tzId = TimeZoneInfo.Local.Id;
        // Convert Windows timezone ID to IANA if needed
        if (OperatingSystem.IsWindows() && 
            TimeZoneInfo.TryConvertWindowsIdToIanaId(tzId, out var ianaId))
        {
            tzId = ianaId;
        }
        return new JsValue(tzId);
    }));

    // Temporal.Now.plainDateISO()
    // Temporal.Now.plainTimeISO()
    // Temporal.Now.plainDateTimeISO()
    // Temporal.Now.zonedDateTimeISO()
    // ...
}

Temporal.Instant

Represents an exact moment in time (UTC).

Creation

// From BigInteger nanoseconds
public JsTemporalInstant(BigInteger epochNanoseconds) { ... }

// From milliseconds (Date.now() compatible)
public static JsTemporalInstant FromEpochMilliseconds(long epochMilliseconds)
{
    return new JsTemporalInstant(epochMilliseconds);
}

// Current time
public static JsTemporalInstant Now()
{
    return new JsTemporalInstant(DateTimeOffset.UtcNow);
}

ISO 8601 String Output

public override string ToString()
{
    var dto = ToDateTimeOffset();
    var nanosPart = (int)(EpochNanoseconds % NanosecondsPerSecond);
    if (nanosPart < 0)
        nanosPart += (int)NanosecondsPerSecond;

    if (nanosPart == 0)
        return dto.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);

    var secondsPart = dto.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
    var nanosStr = nanosPart.ToString("D9", CultureInfo.InvariantCulture).TrimEnd('0');
    return $"{secondsPart}.{nanosStr}Z";
}

Methods

Method Description
add(duration) Add time-only duration components
subtract(duration) Subtract time-only duration components
until(other) Duration between instants
since(other) Duration since other instant
round(options) Round to unit boundary
equals(other) Check equality
toZonedDateTimeISO(timeZone) Convert to zoned

Temporal.Duration

Represents a span of time with multiple components.

Components

flowchart LR
    subgraph Date["Date Components"]
        Y["years"]
        M["months"]
        W["weeks"]
        D["days"]
    end
    
    subgraph Time["Time Components"]
        H["hours"]
        Min["minutes"]
        S["seconds"]
        MS["milliseconds"]
        US["microseconds"]
        NS["nanoseconds"]
    end
Loading

Implementation

public sealed class JsTemporalDuration
{
    public double Years { get; }
    public double Months { get; }
    public double Weeks { get; }
    public double Days { get; }
    public double Hours { get; }
    public double Minutes { get; }
    public double Seconds { get; }
    public double Milliseconds { get; }
    public double Microseconds { get; }
    public double Nanoseconds { get; }

    public int Sign => ComputeSign();
    public bool Blank => Sign == 0;
}

Total Conversion

public double Total(string unit)
{
    // Convert all components to the specified unit
    return unit switch
    {
        "years" => TotalYears(),
        "months" => TotalMonths(),
        "weeks" => TotalWeeks(),
        "days" => TotalDays(),
        "hours" => TotalHours(),
        "minutes" => TotalMinutes(),
        "seconds" => TotalSeconds(),
        "milliseconds" => TotalMilliseconds(),
        "microseconds" => TotalMicroseconds(),
        "nanoseconds" => TotalNanoseconds(),
        _ => throw new ArgumentException($"Unknown unit: {unit}")
    };
}

Temporal.PlainDate

Calendar date without time or timezone.

Properties

Property Type Description
year number ISO year
month number Month (1-12)
day number Day of month
monthCode string "M01"-"M12"
dayOfWeek number 1=Monday, 7=Sunday
dayOfYear number 1-366
weekOfYear number ISO week number
daysInMonth number 28-31
daysInYear number 365 or 366
inLeapYear boolean Leap year check
calendarId string "iso8601"

Arithmetic

public JsTemporalPlainDate Add(JsTemporalDuration duration)
{
    var date = new DateTime(Year, Month, Day);
    
    // Add in order: years, months, weeks, days
    date = date.AddYears((int)duration.Years);
    date = date.AddMonths((int)duration.Months);
    date = date.AddDays((int)duration.Weeks * 7);
    date = date.AddDays((int)duration.Days);
    
    return new JsTemporalPlainDate(date.Year, date.Month, date.Day, Calendar);
}

Temporal.PlainTime

Wall-clock time without date or timezone.

Components

public sealed class JsTemporalPlainTime
{
    public int Hour { get; }        // 0-23
    public int Minute { get; }      // 0-59
    public int Second { get; }      // 0-59
    public int Millisecond { get; } // 0-999
    public int Microsecond { get; } // 0-999
    public int Nanosecond { get; }  // 0-999
}

Rounding

public JsTemporalPlainTime Round(string smallestUnit)
{
    var totalNanos = TotalNanoseconds();
    var divisor = smallestUnit switch
    {
        "hour" => 3600_000_000_000L,
        "minute" => 60_000_000_000L,
        "second" => 1_000_000_000L,
        "millisecond" => 1_000_000L,
        "microsecond" => 1_000L,
        _ => 1L
    };
    var rounded = (totalNanos / divisor) * divisor;
    return FromNanoseconds(rounded);
}

Temporal.ZonedDateTime

Complete date/time with timezone - the most complete representation.

flowchart TB
    ZDT((ZonedDateTime))
    
    subgraph Components
        Date["Date (Y/M/D)"]
        Time["Time (H:M:S.ns)"]
        TZ["Timezone"]
        Cal["Calendar"]
    end
    
    ZDT --> Date
    ZDT --> Time
    ZDT --> TZ
    ZDT --> Cal
    
    subgraph Conversions
        Instant2["toInstant()"]
        PlainDT["toPlainDateTime()"]
        PlainD["toPlainDate()"]
        PlainT["toPlainTime()"]
    end
    
    ZDT --> Instant2
    ZDT --> PlainDT
    ZDT --> PlainD
    ZDT --> PlainT
Loading

Timezone Handling

// Windows to IANA conversion
if (OperatingSystem.IsWindows() && 
    TimeZoneInfo.TryConvertWindowsIdToIanaId(tzId, out var ianaId))
{
    tzId = ianaId;
}

// IANA to Windows conversion for .NET operations
if (!OperatingSystem.IsWindows() &&
    TimeZoneInfo.TryConvertIanaIdToWindowsId(tzId, out var windowsId))
{
    // Use windowsId for TimeZoneInfo.FindSystemTimeZoneById
}

Prototype Caching

Each realm maintains its own set of Temporal prototypes:

internal sealed class TemporalPrototypes
{
    public JsObject InstantPrototype { get; set; } = null!;
    public JsObject DurationPrototype { get; set; } = null!;
    public JsObject PlainDatePrototype { get; set; } = null!;
    public JsObject PlainTimePrototype { get; set; } = null!;
    public JsObject PlainDateTimePrototype { get; set; } = null!;
    public JsObject ZonedDateTimePrototype { get; set; } = null!;
    public JsObject PlainYearMonthPrototype { get; set; } = null!;
    public JsObject PlainMonthDayPrototype { get; set; } = null!;
}

private static readonly ConditionalWeakTable<RealmState, TemporalPrototypes>
    _prototypeCache = new();

Prototype Setup

private static HostFunction CreateInstantConstructor(RealmState realm, TemporalPrototypes prototypes)
{
    var prototype = new JsObject(realm.ObjectPrototype);
    prototypes.InstantPrototype = prototype;
    
    prototype.DefineProperty(SymbolKeys.ToStringTag,
        new PropertyDescriptor { Value = "Temporal.Instant", ... });

    // Add getters
    AddPrototypeGetter(prototype, realm, "epochMilliseconds", thisValue =>
    {
        var instant = GetInstant(thisValue);
        return new JsValue((double)instant.EpochMilliseconds);
    });

    AddPrototypeGetter(prototype, realm, "epochNanoseconds", thisValue =>
    {
        var instant = GetInstant(thisValue);
        return JsValue.FromObjectUnsafe(new JsBigInt(instant.EpochNanoseconds));
    });

    // Add methods
    AddPrototypeMethod(prototype, realm, "toString", 0, (thisValue, _) =>
    {
        var instant = GetInstant(thisValue);
        return new JsValue(instant.ToString());
    });
    
    // ...
}

Internal Slots

Temporal objects store their data in internal slots:

private const string TemporalInstantSlot = "[[TemporalInstant]]";
private const string TemporalDurationSlot = "[[TemporalDuration]]";
private const string TemporalPlainDateSlot = "[[TemporalPlainDate]]";
// ...

private static JsValue WrapInstant(JsTemporalInstant instant, RealmState realm, JsObject prototype)
{
    var obj = new JsObject(prototype);
    obj.SetInternalSlot(TemporalInstantSlot, instant);
    return JsValue.FromJsObject(obj);
}

private static JsTemporalInstant GetInstant(JsValue thisValue)
{
    if (thisValue.TryGetObject<JsObject>(out var obj) &&
        obj.TryGetInternalSlot(TemporalInstantSlot, out var slot) &&
        slot is JsTemporalInstant instant)
    {
        return instant;
    }
    throw StandardLibrary.ThrowTypeError("Invalid Temporal.Instant");
}

Type Coercion

ToTemporalInstant

private static JsTemporalInstant ToTemporalInstant(JsValue arg, RealmState realm)
{
    // Already an Instant
    if (arg.TryGetObject<JsObject>(out var obj) &&
        obj.TryGetInternalSlot(TemporalInstantSlot, out var slot) &&
        slot is JsTemporalInstant instant)
    {
        return instant;
    }

    // Parse from string
    if (arg.IsString)
    {
        var str = arg.AsString();
        return JsTemporalInstant.Parse(str);
    }

    throw StandardLibrary.ThrowTypeError("Cannot convert to Temporal.Instant");
}

ToTemporalDuration

private static JsTemporalDuration ToTemporalDuration(JsValue arg, RealmState realm)
{
    // Already a Duration
    if (arg.TryGetObject<JsObject>(out var obj) &&
        obj.TryGetInternalSlot(TemporalDurationSlot, out var slot) &&
        slot is JsTemporalDuration duration)
    {
        return duration;
    }

    // Parse from string (ISO 8601 duration)
    if (arg.IsString)
    {
        return JsTemporalDuration.Parse(arg.AsString());
    }

    // Build from property bag
    if (arg.TryGetObject<IJsPropertyAccessor>(out var accessor))
    {
        return DurationFromPropertyBag(accessor);
    }

    throw StandardLibrary.ThrowTypeError("Cannot convert to Temporal.Duration");
}

Comparison Operations

All Temporal types implement comparison:

var compare = CreateFunction(realm, "compare", 2, (_, args) =>
{
    var i1 = ToTemporalInstant(args.GetArgument(0), realm);
    var i2 = ToTemporalInstant(args.GetArgument(1), realm);
    return new JsValue(i1.CompareTo(i2));  // -1, 0, or 1
});

Equality

public bool Equals(JsTemporalInstant? other)
{
    return other is not null && EpochNanoseconds.Equals(other.EpochNanoseconds);
}

valueOf Rejection

Temporal types reject implicit conversion to prevent bugs:

AddPrototypeMethod(prototype, realm, "valueOf", 0, (_, _) =>
    throw StandardLibrary.ThrowTypeError(
        "Temporal.Instant.prototype.valueOf does not support implicit conversion", 
        realm: realm));

This prevents accidental comparisons like instant1 < instant2 (use Temporal.Instant.compare() instead).


ISO 8601 Calendar

The default calendar is ISO 8601:

AddPrototypeGetter(prototype, realm, "calendarId", tv => 
    new JsValue(GetPlainDate(tv).Calendar));  // "iso8601"

// ISO 8601 always has 7 days per week
AddPrototypeGetter(prototype, realm, "daysInWeek", _ => new JsValue(7));

// ISO 8601 calendar has no era
AddPrototypeGetter(prototype, realm, "era", _ => JsValue.Undefined);
AddPrototypeGetter(prototype, realm, "eraYear", _ => JsValue.Undefined);

See Also

Clone this wiki locally