I'd like to propose a new set of primitives related to date and time handling.
These new primitives would:
- Ensure decoding is not sensitive to system time-zone and culture settings
- Enable round-trips (and codecs!)
- Generally be less surprising to the user
I'm imagining this as a strictly additive change; it would be fully backward compatible.
If users want the current behaviour, that's still included.
However, due to the issues documented below, they should be encouraged to follow an upgrade path to the new primitives.
I did lots of small experiments to reach this design, but here's the TLDR:
DateTime
- Add
Encode.dateTime
- Add
Decode.dateTime
- Add
Codec.dateTime
- Deprecate
Encode.datetime
- Deprecate
Decode.datetimeUtc
- Deprecate
Decode.datetimeLocal
DateTimeOffset
- Add
Encode.dateTimeOffset
- Add
Decode.dateTimeOffset
- Add
Codec.dateTimeOffset
- Deprecate
Encode.datetimeOffset
- Deprecate
Decode.datetimeOffset
TimeSpan
- Add
Encode.timeSpan
- Add
Decode.timeSpan
- Add
Codec.timeSpan
- Deprecate
Encode.timespan
- Deprecate
Decode.timespan
- Deprecate
Codec.timespan
Background
DateTime
DateTime objects come in three varieties:
DateTimeKind.Utc
DateTimeKind.Local
DateTimeKind.Unspecified.
These are not distinguished by the type system; you can check at run-time using the Kind property.
In order to support round-trips for DateTime in general, we need a way to distinguish these cases in an encoded string.
This can be accomplished with these parameters:
open System
open System.Globalization
let parse (x : string) =
let dt = DateTime.Parse(x, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
dt
let serialize (dt : DateTime) =
dt.ToString("O", CultureInfo.InvariantCulture)
let dts =
[
"2026-01-02T10:20:30.0000000"
"2026-01-02T10:20:30.0000000Z"
"2026-01-02T09:20:30.0000000+00:00"
]
for x in dts do
let dt = parse x
let serialized = serialize dt
let outcome = if x = serialized then "[OK]" else "[FAIL]"
printfn $"%s{x} -> %A{dt} %A{dt.Kind} -> %s{serialized} %s{outcome}"
2026-01-02T10:20:30.0000000 -> 02/01/2026 10:20:30 Unspecified -> 2026-01-02T10:20:30.0000000 [OK]
2026-01-02T10:20:30.0000000Z -> 02/01/2026 10:20:30 Utc -> 2026-01-02T10:20:30.0000000Z [OK]
2026-01-02T09:20:30.0000000+00:00 -> 02/01/2026 09:20:30 Local -> 2026-01-02T09:20:30.0000000+00:00 [OK]
Testing
Note that when testing date-times, you need to be very careful how you compare them!
let a = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)
let b = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified)
printfn $"a = b %b{a = b}"
a = b true
But they have different kinds!
I find expanding then comparing the DateTime objects is a better approach:
open System
let a = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)
let b = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified)
let expand (dt : DateTime) =
dt, dt.Ticks, dt.Kind
printfn $"expand a = expand b %b{expand a = expand b}"
expand a = expand b false
And Unquote can help narrow down the error:
#r "nuget: Unquote, 7.0.1"
open System
open Swensen.Unquote
let a = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)
let b = DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified)
let expand (dt : DateTime) =
dt, dt.Ticks, dt.Kind
test <@ expand a = expand b @>
expand a = expand b
expand 2026-01-02T03:04:05.0000000Z (Utc) = expand 2026-01-02T03:04:05.0000000 (Unspecified)
(02/01/2026 03:04:05, 639029198450000000L, Utc) = (02/01/2026 03:04:05, 639029198450000000L, Unspecified)
false
As you can see, DateTime equality does not compare Kind!
Aside: you might prefer Unquote's test over Expecto's equal; it doesn't even require a message! 😉
DateTimeOffset
DateTimeOffset is equivalent to a UTC date-time plus some time-zone offset.
We can parse and serialize them reliably with these settings:
open System
open System.Globalization
let parse (x : string) =
match DateTimeOffset.TryParse(x, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) with
| true, dto -> dto
| false, _ -> failwith $"Failed to parse `%s{x}`"
let serialize (dt : DateTimeOffset) =
dt.ToString("O", CultureInfo.InvariantCulture)
let xs =
[
"2026-01-02T10:20:30.0000000+00:00"
"2026-01-02T10:20:30.0000000+01:00"
"2026-01-02T09:20:30.0000000+04:00"
]
for x in xs do
let dto = parse x
let serialized = serialize dto
let outcome = if x = serialized then "[OK]" else "[FAIL]"
printfn $"%s{x} -> %A{dto} -> %s{serialized} %s{outcome}"
2026-01-02T10:20:30.0000000+00:00 -> 02/01/2026 10:20:30 +00:00 -> 2026-01-02T10:20:30.0000000+00:00 [OK]
2026-01-02T10:20:30.0000000+01:00 -> 02/01/2026 10:20:30 +01:00 -> 2026-01-02T10:20:30.0000000+01:00 [OK]
2026-01-02T09:20:30.0000000+04:00 -> 02/01/2026 09:20:30 +04:00 -> 2026-01-02T09:20:30.0000000+04:00 [OK]
TimeSpan
TimeSpan represents a duration so it is not subject to time-zones.
However, the encoding can be sensitive to system language settings.
We can parse and serialize them reliably with these settings:
open System
open System.Globalization
let parse (x : string) =
match TimeSpan.TryParse(x, CultureInfo.InvariantCulture) with
| true, ts -> ts
| false, _ -> failwith $"Failed to parse `%s{x}`"
let serialize (dt : TimeSpan) =
dt.ToString("c", CultureInfo.InvariantCulture)
let xs =
[
"00:00:00.0000123"
"07:08:09.0123456"
]
for x in xs do
let ts = parse x
let serialized = serialize ts
let outcome = if x = serialized then "[OK]" else "[FAIL]"
printfn $"%s{x} -> %A{ts} -> %s{serialized} %s{outcome}"
00:00:00.0000123 -> 00:00:00.0000123 -> 00:00:00.0000123 [OK]
07:08:09.0123456 -> 07:08:09.0123456 -> 07:08:09.0123456 [OK]
How do the current decoders behave?
Decode.datetimeUtc
Decode.datetimeUtc does a conversion to universal time.
This breaks round-tripping for Local and Unspecified cases:
#r "nuget: Thoth.Json.Core, 0.9.1"
#r "nuget: Thoth.Json.System.Text.Json, 0.4.0"
open System
open System.Globalization
open Thoth.Json.Core
open Thoth.Json.System.Text.Json
let parse (x : string) =
x
|> Encode.string
|> Encode.toString 0
|> Decode.unsafeFromString Decode.datetimeUtc
let serialize (dt : DateTime) =
dt.ToString("O", CultureInfo.InvariantCulture)
let dts =
[
"2026-01-02T10:20:30.0000000"
"2026-01-02T10:20:30.0000000Z"
"2026-01-02T09:20:30.0000000+00:00"
]
for x in dts do
let dt = parse x
let serialized = serialize dt
let outcome = if x = serialized then "[OK]" else "[FAIL]"
printfn $"%s{x} -> %A{dt} %A{dt.Kind} -> %s{serialized} %s{outcome}"
2026-01-02T10:20:30.0000000 -> 02/01/2026 10:20:30 Utc -> 2026-01-02T10:20:30.0000000Z [FAIL]
2026-01-02T10:20:30.0000000Z -> 02/01/2026 10:20:30 Utc -> 2026-01-02T10:20:30.0000000Z [OK]
2026-01-02T09:20:30.0000000+00:00 -> 02/01/2026 09:20:30 Utc -> 2026-01-02T09:20:30.0000000Z [FAIL]
Decode.datetimeLocal
This decoder uses default DateTimeStyles, making it sensitive to the system time-zone:
#r "nuget: Thoth.Json.Core, 0.9.1"
#r "nuget: Thoth.Json.System.Text.Json, 0.4.0"
open System
open System.Globalization
open Thoth.Json.Core
open Thoth.Json.System.Text.Json
let testTimeZones =
[
"Etc/UTC"
"Europe/London"
"America/New_York"
"Asia/Tokyo"
]
let private gate = obj ()
let runInTimeZone timeZoneId action =
lock gate (fun () ->
let previous = Environment.GetEnvironmentVariable "TZ"
try
Environment.SetEnvironmentVariable("TZ", timeZoneId)
TimeZoneInfo.ClearCachedData()
action ()
finally
Environment.SetEnvironmentVariable("TZ", previous)
TimeZoneInfo.ClearCachedData())
let parse (x : string) =
x
|> Encode.string
|> Encode.toString 0
|> Decode.unsafeFromString Decode.datetimeLocal
let text = "2026-01-02T10:20:30.0000000+01:00"
let expected =
DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
for timeZone in testTimeZones do
runInTimeZone
timeZone
(fun () ->
let dt = parse text
let expand (dt : DateTime) =
dt, dt.Ticks, dt.Kind
let outcome = if expand dt = expand expected then "[OK]" else "[FAIL]"
printfn $"(%s{timeZone}) %A{dt} %s{outcome}"
)
(Etc/UTC) 02/01/2026 09:20:30 [OK]
(Europe/London) 02/01/2026 09:20:30 [OK]
(America/New_York) 02/01/2026 04:20:30 [FAIL]
(Asia/Tokyo) 02/01/2026 18:20:30 [FAIL]
Decode.datetimeOffset
This decoder also uses default DateTimeStyles, making it sensitive to the system time-zone:
#r "nuget: Thoth.Json.Core, 0.9.1"
#r "nuget: Thoth.Json.System.Text.Json, 0.4.0"
open System
open System.Globalization
open Thoth.Json.Core
open Thoth.Json.System.Text.Json
let testTimeZones =
[
"Etc/UTC"
"Europe/London"
"America/New_York"
"Asia/Tokyo"
]
let private gate = obj ()
let runInTimeZone timeZoneId action =
lock gate (fun () ->
let previous = Environment.GetEnvironmentVariable "TZ"
try
Environment.SetEnvironmentVariable("TZ", timeZoneId)
TimeZoneInfo.ClearCachedData()
action ()
finally
Environment.SetEnvironmentVariable("TZ", previous)
TimeZoneInfo.ClearCachedData())
let parse (x : string) =
x
|> Encode.string
|> Encode.toString 0
|> Decode.unsafeFromString Decode.datetimeOffset
let text = "1066-01-02T10:20:30.0000000"
let expected =
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
for timeZone in testTimeZones do
runInTimeZone
timeZone
(fun () ->
let dto = parse text
let expand (dto : DateTimeOffset) =
dto, dto.Ticks, dto.Offset
let outcome = if expand dto = expand expected then "[OK]" else "[FAIL]"
printfn $"(%s{timeZone}) %A{dto} %s{outcome}"
)
(Etc/UTC) 02/01/1066 10:20:30 +00:00 [FAIL]
(Europe/London) 02/01/1066 10:20:30 -00:01 [OK]
(America/New_York) 02/01/1066 10:20:30 -04:57 [FAIL]
(Asia/Tokyo) 02/01/1066 10:20:30 +09:18 [FAIL]
Decode.timespan
The method used by this decoder is sensitive to default system culture:
#r "nuget: Thoth.Json.Core, 0.9.1"
#r "nuget: Thoth.Json.System.Text.Json, 0.4.0"
open System.Globalization
open Thoth.Json.Core
open Thoth.Json.System.Text.Json
let cultures =
[
CultureInfo "en-US"
CultureInfo "fr-FR"
CultureInfo "ru-RU"
]
let tryParse (x : string) =
x
|> Encode.string
|> Encode.toString 0
|> Decode.fromString Decode.timespan
for culture in cultures do
CultureInfo.DefaultThreadCurrentCulture <- culture
CultureInfo.DefaultThreadCurrentUICulture <- culture
let x = tryParse "6:12:14:45,3448"
let outcome = if Result.isOk x then "[OK]" else "[FAIL]"
printfn $"(%A{culture}) %A{x} %s{outcome}"
(en-US) Error "Error at: `$`
Expecting a timespan but instead got: "6:12:14:45,3448"" [FAIL]
(fr-FR) Ok 6.12:14:45.3448000 [OK]
(ru-RU) Ok 6.12:14:45.3448000 [OK]
Proposal
DateTime
Add Decode.dateTime
Note the casing dateTime vs datetime!
This way we can provide an upgrade window for users whilst also fixing a quirk of the naming convention.
This would be the DateTime decoder that users should reach for most of the time. It would ignore system time-zone settings and preserve rountrips.
We can implement it as above:
let dateTime: Decoder<System.DateTime> =
{ new Decoder<System.DateTime> with
member _.Decode(helpers, value) =
if helpers.isString value then
match DateTime.TryParse(helpers.asString value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) with
| true, dt -> Ok dt
| _ -> ("", BadPrimitive("a datetime", value)) |> Error
else
("", BadPrimitive("a datetime", value)) |> Error
}
Add Encode.dateTime
Simply to match naming convention.
Deprecate Encode.datetime
Deprecate Encode.datetime to match naming convention.
Encode.dateTime is a drop-in replacement.
Deprecate Decode.datetimeUtc
Deprecate Decode.datetimeUtc since it breaks rountrips for non-UTC timezone.
Suggested replacement is to move ToUniversalTime() out of the decoding layer, which should be kept pure.
let json = "2026-01-02T10:20:30.0000000"
let dt =
json
|> Decode.unsafeFromString Decode.dateTime
let dtu = dt.ToUniversalTime()
printfn "%A{dtu}"
Deprecate Decode.datetimeLocal
Deprecate Decode.datetimeLocal since it is sensitive to system time-zone settings.
Suggested replacement is Decode.dateTime, which behaves the same in most cases.
However, users should run tests before switching.
DateTimeOffset
Add Decode.dateTimeOffset
Again, note the casing dateTimeOffset vs datetimeOffset!
This would be the DateTimeOffset decoder users reach for most of the time. It would ignore system time-zone settings and preserve rountrips.
We can implement it as above:
let dateTimeOffset: Decoder<System.DateTimeOffset> =
{ new Decoder<System.DateTimeOffset> with
member _.Decode(helpers, value) =
if helpers.isString value then
match DateTimeOffset.TryParse(helpers.asString value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) with
| true, dto -> Ok dto
| _ -> ("", BadPrimitive("a datetimeoffset", value)) |> Error
else
("", BadPrimitive("a datetimeoffset", value)) |> Error
}
Add Encode.dateTimeOffset
Simply to match naming convention.
Deprecate Decode.datetimeOffset
Deprecate Decode.datetimeOffset since it is sensitive to system time-zone settings.
Suggested replacement is Decode.dateTimeOffset, which behaves the same in most cases.
However, users should run tests before switching.
Deprecate Encode.datetimeOffset
Deprecate Encode.datetimeOffset to match naming convention.
Encode.dateTimeOffset is a drop-in replacement.
TimeSpan
Add Decode.timeSpan
Again, note the casing timeSpan vs timespan!
This would be the TimeSpan decoder users reach for most of the time. It would ignore default culture and preserve rountrips.
We can implement it as above:
let timeSpan: Decoder<TimeSpan> =
{ new Decoder<TimeSpan> with
member _.Decode(helpers, value) =
if helpers.isString value then
match TimeSpan.TryParse(helpers.asString value, CultureInfo.InvariantCulture) with
| true, dto -> Ok dto
| _ -> ("", BadPrimitive("a timespan", value)) |> Error
else
("", BadPrimitive("a timespan", value)) |> Error
}
Add Encode.timeSpan
Simply to match naming convention.
Add Codec.timeSpan
Simply to match naming convention.
Deprecate Encode.timespan
Deprecate Encode.timespan to match naming convention.
Encode.timeSpan is a drop-in replacement.
Deprecate Decode.timespan
Deprecate Decode.timespan since it is sensitive to system culture settings.
Suggested replacement is Decode.timeSpan, which behaves the same in most cases.
However, users should run tests before switching.
Deprecate Codec.timespan
Deprecate Codec.timespan since it uses Decode.timespan.
Suggested replacement is Codec.timeSpan; see Decode.timespan.
See also: #107
I'd like to propose a new set of primitives related to date and time handling.
These new primitives would:
I'm imagining this as a strictly additive change; it would be fully backward compatible.
If users want the current behaviour, that's still included.
However, due to the issues documented below, they should be encouraged to follow an upgrade path to the new primitives.
I did lots of small experiments to reach this design, but here's the TLDR:
DateTimeEncode.dateTimeDecode.dateTimeCodec.dateTimeEncode.datetimeDecode.datetimeUtcDecode.datetimeLocalDateTimeOffsetEncode.dateTimeOffsetDecode.dateTimeOffsetCodec.dateTimeOffsetEncode.datetimeOffsetDecode.datetimeOffsetTimeSpanEncode.timeSpanDecode.timeSpanCodec.timeSpanEncode.timespanDecode.timespanCodec.timespanBackground
DateTimeDateTimeobjects come in three varieties:DateTimeKind.UtcDateTimeKind.LocalDateTimeKind.Unspecified.These are not distinguished by the type system; you can check at run-time using the
Kindproperty.In order to support round-trips for
DateTimein general, we need a way to distinguish these cases in an encoded string.This can be accomplished with these parameters:
Testing
Note that when testing date-times, you need to be very careful how you compare them!
But they have different kinds!
I find expanding then comparing the
DateTimeobjects is a better approach:And Unquote can help narrow down the error:
As you can see,
DateTimeequality does not compareKind!Aside: you might prefer Unquote's
testover Expecto'sequal; it doesn't even require a message! 😉DateTimeOffsetDateTimeOffsetis equivalent to a UTC date-time plus some time-zone offset.We can parse and serialize them reliably with these settings:
TimeSpanTimeSpanrepresents a duration so it is not subject to time-zones.However, the encoding can be sensitive to system language settings.
We can parse and serialize them reliably with these settings:
How do the current decoders behave?
Decode.datetimeUtcDecode.datetimeUtcdoes a conversion to universal time.This breaks round-tripping for
LocalandUnspecifiedcases:Decode.datetimeLocalThis decoder uses default
DateTimeStyles, making it sensitive to the system time-zone:Decode.datetimeOffsetThis decoder also uses default
DateTimeStyles, making it sensitive to the system time-zone:Decode.timespanThe method used by this decoder is sensitive to default system culture:
Proposal
DateTimeAdd
Decode.dateTimeNote the casing
dateTimevsdatetime!This way we can provide an upgrade window for users whilst also fixing a quirk of the naming convention.
This would be the
DateTimedecoder that users should reach for most of the time. It would ignore system time-zone settings and preserve rountrips.We can implement it as above:
Add
Encode.dateTimeSimply to match naming convention.
Deprecate
Encode.datetimeDeprecate
Encode.datetimeto match naming convention.Encode.dateTimeis a drop-in replacement.Deprecate
Decode.datetimeUtcDeprecate
Decode.datetimeUtcsince it breaks rountrips for non-UTC timezone.Suggested replacement is to move
ToUniversalTime()out of the decoding layer, which should be kept pure.Deprecate
Decode.datetimeLocalDeprecate
Decode.datetimeLocalsince it is sensitive to system time-zone settings.Suggested replacement is
Decode.dateTime, which behaves the same in most cases.However, users should run tests before switching.
DateTimeOffsetAdd
Decode.dateTimeOffsetAgain, note the casing
dateTimeOffsetvsdatetimeOffset!This would be the
DateTimeOffsetdecoder users reach for most of the time. It would ignore system time-zone settings and preserve rountrips.We can implement it as above:
Add
Encode.dateTimeOffsetSimply to match naming convention.
Deprecate
Decode.datetimeOffsetDeprecate
Decode.datetimeOffsetsince it is sensitive to system time-zone settings.Suggested replacement is
Decode.dateTimeOffset, which behaves the same in most cases.However, users should run tests before switching.
Deprecate
Encode.datetimeOffsetDeprecate
Encode.datetimeOffsetto match naming convention.Encode.dateTimeOffsetis a drop-in replacement.TimeSpanAdd
Decode.timeSpanAgain, note the casing
timeSpanvstimespan!This would be the
TimeSpandecoder users reach for most of the time. It would ignore default culture and preserve rountrips.We can implement it as above:
Add
Encode.timeSpanSimply to match naming convention.
Add
Codec.timeSpanSimply to match naming convention.
Deprecate
Encode.timespanDeprecate
Encode.timespanto match naming convention.Encode.timeSpanis a drop-in replacement.Deprecate
Decode.timespanDeprecate
Decode.timespansince it is sensitive to system culture settings.Suggested replacement is
Decode.timeSpan, which behaves the same in most cases.However, users should run tests before switching.
Deprecate
Codec.timespanDeprecate
Codec.timespansince it usesDecode.timespan.Suggested replacement is
Codec.timeSpan; seeDecode.timespan.See also: #107