9

I have a struct representing a DateTime which also has zone info as below:

public struct DateTimeWithZone
{
    private readonly DateTime _utcDateTime;
    private readonly TimeZoneInfo _timeZone;    
    public DateTimeWithZone(DateTime dateTime, TimeZoneInfo timeZone, 
                        DateTimeKind kind = DateTimeKind.Utc)
    {
        dateTime = DateTime.SpecifyKind(dateTime, kind);        
        _utcDateTime = dateTime.Kind != DateTimeKind.Utc 
                      ? TimeZoneInfo.ConvertTimeToUtc(dateTime, timeZone) 
                      : dateTime;
        _timeZone = timeZone;
    }    
    public DateTime UniversalTime { get { return _utcDateTime; } }
    public TimeZoneInfo TimeZone { get { return _timeZone; } }
    public DateTime LocalTime 
    { 
        get 
        { 
            return TimeZoneInfo.ConvertTime(_utcDateTime, _timeZone); 
        } 
    }
}

I can serialize the object using:

var now = DateTime.Now;
var dateTimeWithZone = new DateTimeWithZone(now, TimeZoneInfo.Local, DateTimeKind.Local);
var serializedDateTimeWithZone = JsonConvert.SerializeObject(dateTimeWithZone);

But when I deserialize it using the below, I get an invalid DateTime value (DateTime.MinValue)

var deserializedDateTimeWithZone = JsonConvert.DeserializeObject<DateTimeWithZone>(serializedDateTimeWithZone);

Any help is much appreciated.

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
MaYaN
  • 6,683
  • 12
  • 57
  • 109
  • 2
    You have too much logic in your class for a serializer. They best work with simple 'plain data' types (i.e. bunch of properties with getters and setters) – mtmk Jul 13 '14 at 19:22
  • 1
    @Maxwell, I am sure my struct has no way as much logic as the built-in DateTime. – MaYaN Jul 13 '14 at 19:34

2 Answers2

17

Just declare the constructor as follows, that's all

[JsonConstructor]
public DateTimeWithZone(DateTime universalTime, TimeZoneInfo timeZone,
                    DateTimeKind kind = DateTimeKind.Utc)
{
    universalTime = DateTime.SpecifyKind(universalTime, kind);
    _utcDateTime = universalTime.Kind != DateTimeKind.Utc
                    ? TimeZoneInfo.ConvertTimeToUtc(universalTime, timeZone)
                    : universalTime;
    _timeZone = timeZone;
}

Note: I only added JsonConstructor attribute and changed the parameter name as universalTime

EZI
  • 15,209
  • 2
  • 27
  • 33
  • This is an oversimplification and it *doesn't* work. Take a look at the JSON itself. There is entirely too much serialized for the time zone. (It should only serialize the time zone id, not every property in the class.) Also, when deserialized, despite using the `JsonConstructor`, the date values still have the wrong values. there's really no shortcut solution. This is a custom value object, and thus a `JsonConverter` is required. – Matt Johnson-Pint Jul 13 '14 at 20:08
  • 2
    I was having an issue with my custom struct. All I needed was the [JsonConstructor] to solve my issue. Thanks for this. – Bobby Cannon Jun 12 '19 at 14:18
14

You need to write a custom JsonConverter to properly serialize and deserialize these values. Add this class to your project.

public class DateTimeWithZoneConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof (DateTimeWithZone) || objectType == typeof (DateTimeWithZone?);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var dtwz = (DateTimeWithZone) value;

        writer.WriteStartObject();
        writer.WritePropertyName("UniversalTime");
        serializer.Serialize(writer, dtwz.UniversalTime);
        writer.WritePropertyName("TimeZone");
        serializer.Serialize(writer, dtwz.TimeZone.Id);
        writer.WriteEndObject();
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var ut = default(DateTime);
        var tz = default(TimeZoneInfo);
        var gotUniversalTime = false;
        var gotTimeZone = false;
        while (reader.Read())
        {
            if (reader.TokenType != JsonToken.PropertyName)
                break;

            var propertyName = (string)reader.Value;
            if (!reader.Read())
                continue;

            if (propertyName == "UniversalTime")
            {
                ut = serializer.Deserialize<DateTime>(reader);
                gotUniversalTime = true;
            }

            if (propertyName == "TimeZone")
            {
                var tzid = serializer.Deserialize<string>(reader);
                tz = TimeZoneInfo.FindSystemTimeZoneById(tzid);
                gotTimeZone = true;
            }
        }

        if (!(gotUniversalTime && gotTimeZone))
        {
            throw new InvalidDataException("An DateTimeWithZone must contain UniversalTime and TimeZone properties.");
        }

        return new DateTimeWithZone(ut, tz);
    }
}

Then register it with the json settings you're using. For example, the default settings can be changed like this:

JsonConvert.DefaultSettings = () =>
{
    var settings = new JsonSerializerSettings();
    settings.Converters.Add(new DateTimeWithZoneConverter());
    return settings;
};

Then it will properly serialize to a usable format. Example:

{
  "UniversalTime": "2014-07-13T20:24:40.4664448Z",
  "TimeZone": "Pacific Standard Time"
}

And it will deserialize properly as well.

If you want to include the local time, You would just add that to the WriteJson method, but it should probably be ignored when deserializing. Otherwise you'd have two different sources of truth. Only one can be authoritative.

Also, you might instead try Noda Time, which includes a ZonedDateTime struct for this exact purpose. There's already support for serialization via the NodaTime.Serialization.JsonNet NuGet package.

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575