Skip to content

RFC: New date and time handling primitives #254

Description

@njlr

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions