Kirk Woll
Dec 02
Implementing "partial date" in C#

Imagine you're implementing a work experience page and you want users to be able to specify date ranges, but they might not want to be specific about the day or month. For example:

Software Engineer
May 2015 - July 2016

But you want to allow for more or less precision (i.e. a full date or just a year)

These classes can be used to faciliate this concept. It supports rendering (.ToString()), parsing, and it provides overrides to control this functionality precisely.

PartialDate.cs

public struct PartialDate
{
    public int? Year { get; }
    public int? Month { get; }
    public int? Day { get; }

    private static readonly PartialDateFormat[] DefaultFormat =
    {
        PartialDateField.Month, " ", PartialDateField.Day, ", ", PartialDateField.Year
    };

    private static readonly PartialDateFormat[] YearFormat = { PartialDateField.Year };

    private static readonly PartialDateFormat[] MonthYearFormat =
    {
        PartialDateField.Month, " ", PartialDateField.Year
    };

    public PartialDate(int? year = null, int? month = null, int? day = null)
    {
        Year = year;
        Month = month;
        Day = day;
    }

    public bool IsEmpty => Year == null && Month == null && Day == null;

    public static PartialDate Parse(string s)
    {
        return Parse(s, new[] { DefaultFormat, MonthYearFormat, YearFormat });
    }

    public static PartialDate Parse(string s, PartialDateFormat[][] patterns)
    {
        if (!TryParse(s, out var result))
            throw new FormatException($"Could not parse '{s}'");
        return result;
    }

    public static bool TryParse(string s, out PartialDate result)
    {
        return TryParse(s, out result, new[] { DefaultFormat, MonthYearFormat, YearFormat });
    }

    public static bool TryParse(string s, out PartialDate result, PartialDateFormat[][] patterns)
    {
        if (s == null)
        {
            result = new PartialDate();
            return true;
        }

        int? year = null;
        int? month = null;
        int? day = null;

        foreach (var pattern in patterns)
        {
            var literals = new Queue<PartialDateFormat>(pattern.Where(x => x.Field.IsLiteralField()));
            foreach (var format in pattern)
            {
                if (format.Field.IsDateField())
                {
                    PartialDateFormat? nextLiteral = literals.Count > 0 ? (PartialDateFormat?)literals.Dequeue() : null;
                    var nextLiteralIndex = nextLiteral == null ? s.Length : s.IndexOf(nextLiteral.Value.Format, StringComparison.Ordinal);
                    if (nextLiteralIndex == -1)
                    {
                        goto next;
                    }

                    var datePart = s.Substring(0, nextLiteralIndex);
                    if (!DateTime.TryParseExact(" " + datePart, " " + format.Format, null, DateTimeStyles.None, out var dateComponent))
                    {
                        goto next;
                    }

                    switch (format.Field)
                    {
                        case PartialDateField.Year:
                            year = dateComponent.Year;
                            break;
                        case PartialDateField.Month:
                            month = dateComponent.Month;
                            break;
                        case PartialDateField.Day:
                            day = dateComponent.Day;
                            break;
                    }

                    s = s.Substring(nextLiteralIndex + (nextLiteral?.Format.Length ?? 0));
                }
            }
            result = new PartialDate(year, month, day);
            return true;

            next: ;
        }

        result = default;
        return false;
    }

    public string Encode()
    {
        return $"{Year?.ToString() ?? "?"}-{Month?.ToString() ?? "?"}-{Day?.ToString() ?? "?"}";
    }

    public static PartialDate Decode(string value)
    {
        if (string.IsNullOrEmpty(value))
            return default;

        var parts = value.Split('-');
        int? ParsePart(string s) => s == "?" ? (int?)null : int.Parse(s);
        return new PartialDate(ParsePart(parts[0]), ParsePart(parts[1]), ParsePart(parts[2]));
    }

    public override string ToString()
    {
        return ToString(DefaultFormat);
    }

    public int? this[PartialDateField field]
    {
        get
        {
            switch (field)
            {
                case PartialDateField.Year:
                    return Year;
                case PartialDateField.Month:
                    return Month;
                case PartialDateField.Day:
                    return Day;
                default:
                    throw new ArgumentException("Only date fields are allowed in the indexer", nameof(field));
            }
        }
    }

    public string ToString(params PartialDateFormat[] formats)
    {
        formats = formats.Length == 0 ? DefaultFormat : formats;

        var builder = new StringBuilder();
        var date = new DateTime(Year ?? 1, Month ?? 1, Day ?? 1);
        for (var i = 0; i < formats.Length; i++)
        {
            var format = formats[i];
            switch (format.Field)
            {
                case PartialDateField.PrefixLiteral:
                    if (i == formats.Length - 1 || !formats[i + 1].Field.IsDateField())
                        throw new ArgumentException("A prefix literal must come directly before a date field", nameof(formats));

                    var prefixValue = this[formats[i + 1].Field];
                    if (prefixValue != null)
                        builder.Append(format.Format);
                    break;
                case PartialDateField.SuffixLiteral:
                    if (i == 0 || !formats[i - 1].Field.IsDateField())
                        throw new ArgumentException("A suffix literal must come directly after a date field");

                    var suffixValue = this[formats[i - 1].Field];
                    if (suffixValue != null)
                        builder.Append(format.Format);
                    break;
                case PartialDateField.Year:
                case PartialDateField.Month:
                case PartialDateField.Day:
                    var value = this[format.Field];
                    if (value != null)
                    {
                        var formattedValue = date.ToString(" " + format.Format).Trim();
                        builder.Append(formattedValue);
                    }
                    break;
            }
        }

        return builder.ToString();
    }

    public bool Equals(PartialDate other)
    {
        return Year == other.Year && Month == other.Month && Day == other.Day;
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)) return false;
        return obj is PartialDate other && Equals(other);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            var hashCode = Year.GetHashCode();
            hashCode = (hashCode * 397) ^ Month.GetHashCode();
            hashCode = (hashCode * 397) ^ Day.GetHashCode();
            return hashCode;
        }
    }
}

PartialDateField.cs

public enum PartialDateField
{
    None,

    /// <summary>
    /// A literal that will be omitted if the next non-literal field is absent
    /// </summary>
    PrefixLiteral,

    /// <summary>
    /// A literal that will be omitted if the previous non-literal field is absent
    /// </summary>
    SuffixLiteral,

    /// <summary>
    /// Outputs the year component of a PartialDate
    /// </summary>
    Year,

    /// <summary>
    /// Outputs the month component of a PartialDate
    /// </summary>
    Month,

    /// <summary>
    /// Outputs the day component of a PartialDate
    /// </summary>
    Day
}

PartialDateFormat.cs

t
{
    public PartialDateField Field { get; }
    public string Format { get; }

    public PartialDateFormat(PartialDateField field) : this()
    {
        if (field == PartialDateField.PrefixLiteral || field == PartialDateField.SuffixLiteral || field == PartialDateField.None)
            throw new ArgumentException(nameof(field));

        Field = field;
        switch (field)
        {
            case PartialDateField.Year:
                Format = "yyyy";
                break;
            case PartialDateField.Month:
                Format = "MMMM";
                break;
            case PartialDateField.Day:
                Format = "d";
                break;
        }
    }

    public PartialDateFormat(string literal) : this()
    {
        Format = literal;
    }

    public PartialDateFormat(PartialDateField field, string format) : this()
    {
        if (field == PartialDateField.None)
            throw new ArgumentException(nameof(field));

        Field = field;
        Format = format;
    }

    public static implicit operator PartialDateFormat(PartialDateField field)
    {
        return new PartialDateFormat(field);
    }

    public static implicit operator PartialDateFormat(string literal)
    {
        return Suffix(literal);
    }

    public static PartialDateFormat Prefix(string prefixLiteral)
    {
        return new PartialDateFormat(PartialDateField.PrefixLiteral, prefixLiteral);
    }

    public static PartialDateFormat Suffix(string suffixLiteral)
    {
        return new PartialDateFormat(PartialDateField.SuffixLiteral, suffixLiteral);
    }
}

PartialDateExtensions.cs

public static class PartialDateFieldExtensions
{
    public static bool IsDateField(this PartialDateField field)
    {
        return field == PartialDateField.Year || field == PartialDateField.Month || field == PartialDateField.Day;
    }

    public static bool IsLiteralField(this PartialDateField field)
    {
        return field == PartialDateField.PrefixLiteral || field == PartialDateField.SuffixLiteral;
    }
}

Note your comment will be put in a review queue before being published.