DateOnly, TimeOnly and DateTimeOffset

Designed, not built. Nothing on this page compiles today; every sample carries // no-run for that reason. What runs is the type reference, and this page is kept apart from it on purpose (the specs index).

This page is a proposed section of datetime.md, not a design of its own. That page designs DateTime and TimeSpan and lists DateOnly and TimeOnly under "What stays out" with the reason "C# has them; nothing asks for them yet", and DateTimeOffset beside the time-zone database. Something asks for them now — the owner's "types are missing" — and the answer is short, because all three are derived: they add three registrations, one packing decision and a handful of rows to tables that page already builds. It is kept in a file of its own only so that the two branches do not conflict; when both are merged it should be folded in as datetime.md § 15 and this file deleted.

Everything not restated here is that page's: the primitive-member lowering table (§ 2), the conversion clause (§ 5), the calendar in lib/time.tk (§ 6), the "o"/"s" formats (§ 7) and the mc wall-clock ask (§ 8).


1. Representation #

typewidthalignkindwhat it holds
DateOnly44TK_SINTa day number, 0 .. 3652058, from 0001-01-01
TimeOnly88TK_SINTticks since midnight, 0 .. 863999999999
DateTimeOffset1616TK_WIDE+0 the UTC tick count as a DateTime, +8 the offset in minutes, signed

DateOnly and TimeOnly are C#'s own representations exactly — DayNumber is an int, Ticks is a long — and neither needs a machine, a literal or an intrinsic, for the same one-line reason TimeSpan does not.

DateTimeOffset is the one that does not fit. C# stores a DateTime plus a short of offset minutes, and the two do not fit in eight bytes here: the ticks need 62 bits (the dateData packing datetime.md § 1 documents) and an offset of -14:00 .. +14:00 needs another 12. Rather than shave the tick range, it becomes the third TK_WIDE type, moved by the same teko_wide.tk decimal and Guid already use, by address and never in a register pair. It therefore lands after docs/specs/decimal.md's C3 and adds no machine work of its own.

The +0 half is a DateTime whose Kind bits are always Utc, which is C#'s own invariant: a DateTimeOffset stores the instant in UTC and the offset separately, so two values with different offsets that name the same instant are equal and compare equal. .DateTime re-applies the offset and hands back an Unspecified DateTime; .UtcDateTime hands back the +0 half as it is.

2. The surface #

Legal.

// no-run
#include "time.tk"

i64 main() {
    DateOnly d = new DateOnly(2024, 2, 29);
    if (d.Year != 2024) return 1;
    if (d.Month != 2) return 2;
    if (d.Day != 29) return 3;
    if (d.DayOfWeek != 4) return 4;                    // a Thursday
    if (d.AddDays(1).Month != 3) return 5;
    if (d.ToString() != "2024-02-29") return 6;

    TimeOnly t = new TimeOnly(13, 45, 30);
    if (t.Hour != 13) return 7;
    if (t.Ticks != 495300000000) return 8;
    TimeSpan since = t - new TimeOnly(12, 45, 30);      // a TimeSpan
    if (since.TotalHours != 1.0) return 9;
    if (t.AddHours(11).Hour != 0) return 10;           // it wraps at midnight

    DateTime dt = d.ToDateTime(t);
    if (dt.Day != 29) return 11;
    if (DateOnly.FromDateTime(dt) != d) return 12;

    DateTimeOffset o = new DateTimeOffset(dt, TimeSpan.FromHours(0 - 3));
    if (o.Offset.TotalHours != 0.0 - 3.0) return 13;
    if (o.UtcDateTime.Hour != 16) return 14;           // 13:45 at -03:00 is 16:45 UTC
    if (o.ToString() != "2024-02-29T13:45:30.0000000-03:00") return 15;

    DateTimeOffset p = new DateTimeOffset(o.UtcDateTime, TimeSpan.Zero);
    if (o != p) return 16;                             // the same instant: equal
    return 42;
}

Illegal, and what each one earns.

// no-run
#include "time.tk"

i64 main() {
    DateOnly d = new DateOnly(2024, 2, 29);
    TimeOnly t = new TimeOnly(13, 45, 30);

    i64 n = d;                    // teko: a value of type DateOnly does not convert to i64
    DateOnly e = 5;               // teko: a value of type i64 does not convert to DateOnly
    DateTime x = d;               // teko: a value of type DateOnly does not convert to DateTime
    DateOnly f = d + t;           // teko: no operator `+` takes these operands
    i64 c = (i64) d;              // teko: a date does not cast; `.DayNumber` reads it
    DateOnly g = new DateOnly(2024, 2, 30);   // teko: that date does not exist  (exit 70)
    TimeOnly h = new TimeOnly(24, 0, 0);      // teko: that time of day does not exist  (exit 70)
    return 0;
}
writtenmessage
any of the three in a numeric slotteko: a value of type DateOnly does not convert to i64
an integer in any of the threeteko: a value of type i64 does not convert to DateOnly
a DateOnly in a DateTime slot, or the reverseteko: a value of type DateOnly does not convert to DateTime — .ToDateTime(t) and DateOnly.FromDateTime(d) are the two roads
d + tteko: no operator `+` takes these operands — C# has no such operator either; d.ToDateTime(t) is the form
(i64) d written by handteko: a date does not cast; `.DayNumber` reads it and `DateOnly.FromDayNumber(n)` builds it
a day that does not existteko: that date does not exist, exit 70
an hour, minute or second out of rangeteko: that time of day does not exist, exit 70
an offset outside -14:00 .. +14:00, or not a whole minuteteko: that UTC offset does not exist, exit 70
DateTimeOffset.Now, UtcNowteko: DateTimeOffset.Now is not taught yet — the same wall clock datetime.md § 8 is blocked on

3. Operators #

Every row is a call into lib/time.tk; nothing is left to the core's own arithmetic, for the reason datetime.md § 4 gives.

writtenlowered toresult
d == d, !=, <, <=, >, >= (DateOnly)tk_do_eq … tk_do_gei64 0/1
t == t and the other five (TimeOnly)tk_to_eq … tk_to_gei64 0/1
t - ttk_to_subTimeSpan — C#'s own, the elapsed time, never negative
o == o and the other five (DateTimeOffset)tk_dto_eq … tk_dto_gei64 0/1, on the instant, offset ignored
o - otk_dto_subTimeSpan
o + t, o - t (t a TimeSpan)tk_dto_add, tk_dto_sub_tsDateTimeOffset, offset kept
d + t, d - d, t + trefusedC# has none of the three

DateOnly has no - in C# either: d1.DayNumber - d2.DayNumber is the form, and teko keeps it rather than inventing an operator C# does not have.

4. The API #

DateOnly

staticinstance
DateOnly.MinValue, MaxValue.Year .Month .Day .DayOfWeek .DayOfYear .DayNumber — all i64
new DateOnly(y, m, d).AddDays(i64) .AddMonths(i64) .AddYears(i64)
DateOnly.FromDayNumber(i64).ToDateTime(TimeOnly) → DateTime
DateOnly.FromDateTime(DateTime).CompareTo .Equals .ToString()
DateOnly.Parse(str), TryParse(str, out DateOnly)

TimeOnly

staticinstance
TimeOnly.MinValue, MaxValue.Hour .Minute .Second .Millisecond .Ticks
new TimeOnly(h, mi, s), new TimeOnly(h, mi, s, ms).Add(TimeSpan) .AddHours(f64) .AddMinutes(f64) — all wrapping, C#'s own
new TimeOnly(i64 ticks).ToTimeSpan() → TimeSpan
TimeOnly.FromDateTime(DateTime), FromTimeSpan(TimeSpan).IsBetween(TimeOnly, TimeOnly)
TimeOnly.Parse(str), TryParse(str, out TimeOnly).CompareTo .Equals .ToString()

DateTimeOffset

staticinstance
DateTimeOffset.MinValue, MaxValue, UnixEpoch.Offset → TimeSpan, .TotalOffsetMinutes → i64
new DateTimeOffset(DateTime, TimeSpan).UtcDateTime .LocalDateTime .DateTime → DateTime
new DateTimeOffset(i64 ticks, TimeSpan).Year … .Millisecond, of the local reading
DateTimeOffset.FromUnixTimeSeconds(i64), FromUnixTimeMilliseconds(i64).ToUnixTimeSeconds(), .ToUnixTimeMilliseconds()
DateTimeOffset.Parse(str), TryParse(str, out DateTimeOffset).ToOffset(TimeSpan), .CompareTo, .Equals, .ToString()
DateTimeOffset.Now, UtcNow — blocked

.LocalDateTime is .DateTime under another name here: teko has no time-zone database (datetime.md § 8), so "local" means "the offset this value carries" and nothing more. It is kept because C# code reads it and because refusing it would be refusing a member that does have an honest meaning; what it does not do — consult the host's zone — is stated in its own documentation row.

5. Text #

All three extend datetime.md § 7's two formats and add nothing new:

typeToString()notes
DateOnly2024-02-2910 characters; "o" and "s" are the same string
TimeOnly13:45:30.123456716 characters; "s" drops the fraction, giving 13:45:30
DateTimeOffset2024-02-29T13:45:30.1234567-03:00"o"; "s" is the 19-character form with no offset, as C# does

Parse accepts exactly what ToString writes, panics on anything else (teko: the string is not a date, teko: the string is not a time of day), and TryParse answers 0/1 and writes MinValue on failure — the same pair, the same reason.

6. What it costs in mc limits #

rowbeforeafterwhy
types—+3three type_new calls in the compiler itself
syntax—+3DateOnly.MinValue and its two siblings need the type word to open an expression
alias, passes, intrin—unmovedrows in tables that exist, and functions in lib/time.tk

7. Fixtures #

fixtureassertsexpect-exit
tests/primitives_dateonly.tk2024-02-29 built three ways and read back component by component; DayOfWeek and DayOfYear; AddDays over a month and a year boundary; AddMonths clamping 2024-01-31 to 2024-02-29; MinValue/MaxValue; the six comparisons; DayNumber round-trip; ToString/Parse42
tests/primitives_timeonly.tkcomponents; Ticks; AddHours wrapping past midnight in both directions; t - t as a TimeSpan; IsBetween across midnight; ToTimeSpan; the six comparisons; ToString/Parse in both formats42
tests/primitives_dto.tka value through a local, a parameter, a return, a field, a global and an array element, and a recursive function proving the sixteen-byte return buffer; two values with different offsets naming one instant compare equal; ToOffset preserves the instant; the Unix conversions round-trip; ToString/Parse42
tests/primitives_dateonly_bad.tknew DateOnly(2024, 2, 30)70
tests/primitives_timeonly_bad.tknew TimeOnly(24, 0, 0)70
tests/primitives_dto_bad_offset.tknew DateTimeOffset(dt, TimeSpan.FromHours(15))70

8. The crumbs #

N4 — DateOnly and TimeOnly (M) #

Two type_new calls, two syntax_expr registrations, the member and operator rows, and the lib/time.tk half that converts a day number to a civil date — which datetime.md's C2 has already written for DateTime and which this crumb calls rather than copies. Depends on docs/specs/datetime.md's C2 and on nothing else.

Gate: primitives_dateonly.tk and _timeonly.tk at 42, the two _bad.tk at 70; every other fixture unchanged with --dump-ast byte-identical; FIXPOINT OK; mc limits verdict ok with passes and intrin not moved; sh scripts/check-docs.sh green. Owes: the two sections in types.md, the refusals in diagnostics.md, the functions in runtime.md, and the removal of the "left out" row in datetime.md § 8.

N5 — DateTimeOffset (M) #

One type_new of sixteen bytes, the offset validation, the instant-based comparison, the Unix conversions, ToOffset and the "o" format with its offset suffix. Depends on docs/specs/datetime.md's C2 and on docs/specs/decimal.md's C3 — the second only for the sixteen-byte machine, which by then already carries decimal and Guid.

Gate: primitives_dto.tk at 42 on all five legs — a sixteen-byte value's ABI is what a single leg cannot prove — _bad_offset.tk at 70, and everything N4 gated on. Owes: the section in types.md, diagnostics.md, runtime.md, the Now/UtcNow row in not-yet.md, and the removal of the "left out" row in datetime.md § 8.

9. Risks and law tensions #

tensionrecommended resolution
DateTimeOffset needs sixteen bytes and datetime.md promised its two types needed no machine.It does not break that promise, it inherits another page's: teko_wide.tk exists for decimal and already carries Guid by the time this lands, so N5 adds no machine work at all. It is also why N5 is a separate crumb from N4 — DateOnly and TimeOnly are free of that dependency and should not wait behind it.
.LocalDateTime without a time-zone database.It means "the offset this value carries", it is documented as exactly that, and DateTimeOffset.Now — the one member that would need the host's zone — is refused by name and blocked on the same wall clock datetime.md § 8 asks for. Refusing the whole member would refuse something that has an honest meaning.
d + t reads like it should work. A DateOnly plus a TimeOnly is a DateTime to every reader.C# has no such operator and d.ToDateTime(t) is its form, so the refusal is C#'s own, with the method named in the neighbouring row of the table. Adding the operator would be teko inventing surface, which D3 does not allow.
Three more type words taken from every program. DateOnly, TimeOnly, DateTimeOffset.The same cost every type_new has, checked over the whole tree in each crumb's gate. They are C#'s spellings and long enough that a collision is unlikely; a collision found in the gate is the finding, not a reason to rename.
This page and datetime.md will conflict when both branches merge.It is written as a standalone file for exactly that reason, it restates nothing that page owns, and folding it in as datetime.md § 15 and deleting this file is a mechanical step on whichever branch merges second.

10. What the mc channel is asked #

Nothing new. DateTimeOffset.Now and UtcNow are blocked on the wall clock datetime.md § 14 already asks for, and nothing else on this page needs mc to change.

Edit this page