Parsing Santa's workshop with strongly typed data (without the coal)

Dave Brock
Dave Brock

This is my post for the 2025 C# Advent. Check out all the great posts!

Every year, Santa's workshop runs on data: delivery manifests, elf schedules, chimney dimensions, and naughty/nice scores. And like any other legacy system, it's held together with strings, DateTime comparisons, and hope.

In this post, let's modernize Santa's data parsing using modern C# features:

  • DateOnly / TimeOnly
  • IParsable<TSelf> / ISpanParsable<TSelf>
  • Strongly typed IDs
  • C# 14's new extension members

The goal here is for more safety, clearer intent, and better performance — all without turning the code into a Christmas puzzle.

ℹ️
This post targets .NET 10 / C# 14 for the extension member examples. The other features (DateOnly/TimeOnly, IParsable/ISpanParsable, readonly record struct) are available starting in .NET 6/7.

The legacy Santa codebase

Here's a real "elves stay late" classic:

public void AssignGiftToElf(int giftId, int elfId, int workshopId)
{
  Console.WriteLine($"Assigning gift {giftId} to elf {elfId} in workshop {workshopId}");
}

AssignGiftToElf(elfId, giftId, workshopId); 

All of our parameters are int types. The compiler can't help you if you get the order wrong.

Santa's delivery logic isn't much better (sorry, children, this year not everything is delivered at midnight):

public class DeliveryWindow
{
    public DateTime DeliveryDate { get; set; }

    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }

    public bool IsDuringSleepingHours()
    {
        var startHour = StartTime.Hour;
        var endHour = EndTime.Hour;
        
        return startHour >= 22 || endHour <= 6;
    }
}

We're overusing DateTime for both pure dates and pure times. What time is on DeliveryDate? What date is attached to StartTime? What timezone are these in? You have to know tribal knowledge that isn’t expressed in the type system.

Let's see how we process our presents.

public class DeliveryOrder
{
    public int GiftId { get; set; }
    public int ElfId { get; set; }
    public int WorkshopId { get; set; }
    
    public DateTime DeliveryDate { get; set; }
    public DateTime WindowStart { get; set; }
    public DateTime WindowEnd { get; set; }
    
    public string? ChimneyDimensions { get; set; }
    public string? RecipientName { get; set; }
    public string? Address { get; set; }
}

The IDs are just int types (easy to mix up), dates and times are all DateTime (potentially confusing), and the ChimneyDimensions is a raw string we'll need to parse manually every time we need it.

These are potentially critical bugs that'll keep Santa's developers up late. Let's fix them together.

Use DateOnly and TimeOnly to say what you mean

First thing first: stop using DateTime when you don't actually mean date and time.

Here's a cleaner DeliveryWindow using DateOnly and TimeOnly:

public record DeliveryWindow
{
    public required DateOnly DeliveryDate { get; init; }
    public required TimeOnly WindowStart { get; init; }
    public required TimeOnly WindowEnd { get; init; }

    public bool IsDuringSleepingHours()
    {
        var sleepStart = new TimeOnly(22, 0); // 10 PM
        var sleepEnd = new TimeOnly(6, 0);    // 6 AM

        return WindowStart >= sleepStart || WindowEnd <= sleepEnd;
    }
}

Now the type tells you exactly what’s going on:

  • DeliveryDate is a calendar date, no time, no timezone debates
  • WindowStart / WindowEnd are times of day

There's also a performance bonus: DateOnly is half the size of DateTime.

Console.WriteLine(Unsafe.SizeOf<DateTime>());  // 8 bytes
Console.WriteLine(Unsafe.SizeOf<DateOnly>());  // 4 bytes
Console.WriteLine(Unsafe.SizeOf<TimeOnly>());  // 8 bytes

That may seem like a small difference, but not when you're storing millions of delivery records.

Starting with EF Core 8, DateOnly and TimeOnly are supported out of the box. DateOnly maps to the SQL date type, and TimeOnly maps to time.

ℹ️
If you're on .NET Framework or EF Core 7 or earlier, you'll typically stick with DateTime (or use DateOnly/TimeOnly with value converters). Just make sure to be disciplined about .Date comparisons and document times that are ignored.

Use strongly typed IDs for compile-time safety

Let's fix the "everything is an int" problem.

Instead of:

public void AssignGiftToElf(int giftId, int elfId, int workshopId)
{
    Console.WriteLine($"Assigning gift {giftId} to elf {elfId} in workshop {workshopId}");
}

AssignGiftToElf(elfId, giftId, workshopId); // Compiles, but there be bugs

Let's wrap each ID in its own type using readonly record struct:

public readonly record struct GiftId(int Value);
public readonly record struct ElfId(int Value);
public readonly record struct WorkshopId(int Value);

Now our method looks like this:

public void AssignGiftToElf(GiftId giftId, ElfId elfId, WorkshopId workshopId)
{
  Console.WriteLine($"Assigning gift {giftId} to elf {elfId} in workshop {workshopId}");
}

AssignGiftToElf(new ElfId(42), new GiftId(17), new WorkshopId(3));

The call now fails with a helpful error (Cannot convert from 'ElfId' to 'GiftId') instead of silently mis-routing gifts.

In terms of runtime overhead, see how the abstraction disappears after we compile:

using System;
using System.Runtime.CompilerServices;

Console.WriteLine(Unsafe.SizeOf<int>());
Console.WriteLine(Unsafe.SizeOf<GiftId>());
Console.WriteLine(Unsafe.SizeOf<ElfId>());
Console.WriteLine(Unsafe.SizeOf<WorkshopId>());

All four of these lines output 4, the same size as the underlying int.

As a bonus, our record struct update gives us value equality for free.

var id1 = new GiftId(42);
var id2 = new GiftId(42);
var id3 = new GiftId(43);

Console.WriteLine(id1 == id2); // true
Console.WriteLine(id1 == id3); // false
Console.WriteLine(id1.GetHashCode() == id2.GetHashCode()); // true

Use IParsable<TSelf> for generic parsing

Let's talk about our favorite topic: parsing.

Our original ChimneyDimensions is pretty ad-hoc:

public class ChimneyDimensions
{
    public double WidthInches { get; set; }
    public double HeightInches { get; set; }

    public static ChimneyDimensions Parse(string input)
    {
        var parts = input.Split('x');
        return new ChimneyDimensions
        {
            WidthInches = double.Parse(parts[0]),
            HeightInches = double.Parse(parts[1])
        };
    }

    public bool CanSantaFit() => WidthInches >= 20 && HeightInches >= 24;
}

Sure, this "works." (Imagine my air quotes.) But every type needs its own bespoke Parse method. There's no TryParse, no IFormatProvider for culture handling (important for Santa's global reach!), and we can't write generic code that parses multiple types the same way.

This is where IParsable<TSelf> comes in handy. While you can always write a Parse method, this makes sure your types easily work with generic utilities, framework features, and other developer code.

Let's evolve ChimneyDimensions into a modern, value-type version:

public readonly record struct ChimneyDimensions : IParsable<ChimneyDimensions>
{
    public double WidthInches  { get; init; }
    public double HeightInches { get; init; }

    public ChimneyDimensions(double width, double height)
        => (WidthInches, HeightInches) = (width, height);

    public static ChimneyDimensions Parse(string s, IFormatProvider? provider)
    {
        var i = s.IndexOf('x');
        if (i < 0)
            throw new FormatException($"Expected 'WIDTHxHEIGHT', got '{s}'");

        var width  = double.Parse(s[..i],     provider);
        var height = double.Parse(s[(i + 1)..], provider);
        return new(width, height);
    }

    public static bool TryParse(string? s, IFormatProvider? provider,
        out ChimneyDimensions result)
    {
        if (string.IsNullOrEmpty(s))
        {
            result = default;
            return false;
        }

        var i = s.IndexOf('x');
        if (i < 0 ||
            !double.TryParse(s[..i], provider, out var width) ||
            !double.TryParse(s[(i + 1)..], provider, out var height))
        {
            result = default;
            return false;
        }

        result = new(width, height);
        return true;
    }

    public bool CanSantaFit() => WidthInches >= 20 && HeightInches >= 24;

    public override string ToString() => $"{WidthInches}x{HeightInches}";
}

Now we can write generic parsing utilities once and reuse everywhere.

public static class GenericParser
{
    public static T Parse<T>(string value) where T : IParsable<T>
        => T.Parse(value, CultureInfo.InvariantCulture);

    public static IEnumerable<T> ParseMany<T>(IEnumerable<string> values)
        where T : IParsable<T>
        => values.Select(Parse<T>);
}

var giftId  = GenericParser.Parse<GiftId>("12345");
var chimney = GenericParser.Parse<ChimneyDimensions>("24x36");
var date    = GenericParser.Parse<DateOnly>("2024-12-25");
ℹ️
This works for both your custom types and BCL types (int, DateOnly, etc.) as long as they implement IParsable<TSelf>.

Use ISpanParsable<TSelf> for zero-allocation parsing

IParsable<TSelf> gives us generic parsing, but Santa's Workshop is stressing about performance. We have a 10GB delivery manifest to parse, and those string.Split() allocations add up. GC pressure is killing our throughput.

Our old way allocated strings for every field.

var parts = input.Split('x');  
var width = double.Parse(parts[0]);  
var height = double.Parse(parts[1]);

The Split call allocates a string[], and each new element is a new string object. Multiply that by millions of lines and we're on Santa's Naughty List.

ISpanParsable<TSelf> let us parse from ReadOnlySpan<char> (slices of the original string with zero allocations). We can then extend ChimneyDimensions:

public readonly record struct ChimneyDimensions :
    IParsable<ChimneyDimensions>, ISpanParsable<ChimneyDimensions>
{
    public double WidthInches  { get; init; }
    public double HeightInches { get; init; }

    public ChimneyDimensions(double width, double height)
        => (WidthInches, HeightInches) = (width, height);

    // string-based overload delegates to the span-based one
    public static ChimneyDimensions Parse(string s, IFormatProvider? provider)
        => Parse(s.AsSpan(), provider);

    public static ChimneyDimensions Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
    {
        var i = s.IndexOf('x');
        if (i < 0)
            throw new FormatException("Expected 'WIDTHxHEIGHT'");

        var width  = double.Parse(s[..i],     provider);
        var height = double.Parse(s[(i + 1)..], provider);
        return new(width, height);
    }

    public static bool TryParse(string? s, IFormatProvider? provider,
        out ChimneyDimensions result)
    {
        if (string.IsNullOrEmpty(s))
        {
            result = default;
            return false;
        }

        return TryParse(s.AsSpan(), provider, out result);
    }

    public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider,
        out ChimneyDimensions result)
    {
        var i = s.IndexOf('x');
        if (i < 0 ||
            !double.TryParse(s[..i], provider, out var width) ||
            !double.TryParse(s[(i + 1)..], provider, out var height))
        {
            result = default;
            return false;
        }

        result = new(width, height);
        return true;
    }

    public bool CanSantaFit() => WidthInches >= 20 && HeightInches >= 24;

    public override string ToString() => $"{WidthInches}x{HeightInches}";
}

Now let’s parse an entire delivery manifest line.

First, the original approach uses Split, which allocates a string[] with nine elements:

public static DeliveryOrder ParseLineOldWay(string line)
{
    var parts = line.Split('|');
    
    return new DeliveryOrder
    {
        GiftId        = new GiftId(int.Parse(parts[0], CultureInfo.InvariantCulture)),  
        AssignedElf   = new ElfId(int.Parse(parts[1], CultureInfo.InvariantCulture)),  
        OriginWorkshop= new WorkshopId(int.Parse(parts[2], CultureInfo.InvariantCulture)), 
        DeliveryDate  = DateOnly.Parse(parts[3], CultureInfo.InvariantCulture),    
        WindowStart   = TimeOnly.Parse(parts[4], CultureInfo.InvariantCulture),    
        WindowEnd     = TimeOnly.Parse(parts[5], CultureInfo.InvariantCulture),    
        Chimney       = ChimneyDimensions.Parse(parts[6], CultureInfo.InvariantCulture),
        RecipientName = parts[7],
        Address       = parts[8]
    };
}

Allocations everywhere.

Our updated version uses a tiny helper:

static ReadOnlySpan<char> ReadField(ref ReadOnlySpan<char> line)
{
    var i = line.IndexOf('|');
    if (i < 0)
    {
        var field = line;
        line = ReadOnlySpan<char>.Empty;
        return field;
    }

    var result = line[..i];
    line = line[(i + 1)..];
    return result;
}

Now parsing the line is concise and allocation-friendly.

public static DeliveryOrder ParseLineNewWay(ReadOnlySpan<char> line)
{
    var giftId       = new GiftId(int.Parse(ReadField(ref line), CultureInfo.InvariantCulture));
    var elfId        = new ElfId(int.Parse(ReadField(ref line), CultureInfo.InvariantCulture));
    var workshopId   = new WorkshopId(int.Parse(ReadField(ref line), CultureInfo.InvariantCulture));
    var deliveryDate = DateOnly.Parse(ReadField(ref line), CultureInfo.InvariantCulture);
    var windowStart  = TimeOnly.Parse(ReadField(ref line), CultureInfo.InvariantCulture);
    var windowEnd    = TimeOnly.Parse(ReadField(ref line), CultureInfo.InvariantCulture);
    var chimney      = ChimneyDimensions.Parse(ReadField(ref line), CultureInfo.InvariantCulture);
    var recipient    = ReadField(ref line).ToString();
    var address      = ReadField(ref line).ToString();

    return new DeliveryOrder
    {
        GiftId        = giftId,
        AssignedElf   = elfId,
        OriginWorkshop= workshopId,
        DeliveryDate  = deliveryDate,
        WindowStart   = windowStart,
        WindowEnd     = windowEnd,
        Chimney       = chimney,
        RecipientName = recipient,
        Address       = address
    };
}

We still allocate strings for RecipientName and Address because we need to store them in the resulting object; everything else is parsed directly from span slices.

Use C# 14 extension members to extend everything

C# 14 introduces extension blocks, which let you add not just methods, but properties and static members to existing types.

With classic extension methods, you can only add methods. They always look like method calls:

public static class DeliveryOrderExtensions
{
    public static bool IsUrgent(this DeliveryOrder order)
        => order.DeliveryDate <= DateOnly.FromDateTime(DateTime.Today.AddDays(1));

    public static TimeSpan GetWindowDuration(this DeliveryOrder order)
        => order.WindowEnd.ToTimeSpan() - order.WindowStart.ToTimeSpan();
}

With no way to define extension properties, we have to use GetWindowDuration() instead of just WindowDuration.

C# 14 extension blocks change this. (Yes, I don't like the syntax either; don't kill the messenger!)

public static class DeliveryExtensions
{
    extension(DeliveryOrder order)
    {
        public bool IsUrgent
            => order.DeliveryDate <= DateOnly.FromDateTime(DateTime.Today.AddDays(1));

        public TimeSpan WindowDuration
            => order.WindowEnd.ToTimeSpan() - order.WindowStart.ToTimeSpan();

        public bool FitsChimney(double santaWidth, double santaHeight)
            => order.Chimney.WidthInches >= santaWidth &&
               order.Chimney.HeightInches >= santaHeight;

        public string ToManifestLine()
            => $"{order.GiftId}|{order.AssignedElf}|{order.OriginWorkshop}|" +
               $"{order.DeliveryDate:yyyy-MM-dd}|{order.WindowStart:HH:mm}|" +
               $"{order.WindowEnd:HH:mm}|{order.Chimney}|{order.RecipientName}|" +
               $"{order.Address}";
    }

    extension(DateOnly)
    {
        public static DateOnly ChristmasThisYear
            => new(DateTime.Today.Year, 12, 25);

        public static DateOnly NextChristmas()
        {
            var today     = DateOnly.FromDateTime(DateTime.Today);
            var christmas = new DateOnly(today.Year, 12, 25);

            return today > christmas
                ? new DateOnly(today.Year + 1, 12, 25)
                : christmas;
        }
    }
}

Now we can use extension properties like native properties—even more, static extensions appear directly on the type.

var order = new DeliveryOrder
{
    GiftId        = new GiftId(123),
    AssignedElf   = new ElfId(42),
    OriginWorkshop= new WorkshopId(3),
    DeliveryDate  = DateOnly.FromDateTime(DateTime.Today),
    WindowStart   = new TimeOnly(23, 0),
    WindowEnd     = new TimeOnly(23, 30),
    Chimney       = new ChimneyDimensions(24, 36),
    RecipientName = "Charlie",
    Address       = "123 Candy Cane Lane"
};

if (order.IsUrgent)
{
    Console.WriteLine($"Rush delivery! Window: {order.WindowDuration}");
}

if (order.FitsChimney(20, 24))
{
    Console.WriteLine("Santa approved!");
}

var christmas = DateOnly.ChristmasThisYear;
var next      = DateOnly.NextChristmas();

IsUrgent and WindowDuration are properties now, and ChristmasThisYear looks like it's built into DateOnly.

Putting it all together

Finally! Here's our modernized DeliveryOrder that uses everything we discussed.

public record DeliveryOrder
{
    public required GiftId GiftId { get; init; }
    public required ElfId AssignedElf { get; init; }
    public required WorkshopId OriginWorkshop { get; init; }
    public required DateOnly DeliveryDate { get; init; }
    public required TimeOnly WindowStart { get; init; }
    public required TimeOnly WindowEnd { get; init; }
    public required ChimneyDimensions Chimney { get; init; }
    public required string RecipientName { get; init; }
    public required string Address { get; init; }
}

Everything we wanted is more explicit:

  • Strongly-typed IDs: we can't mix up GiftId with ElfId
  • Clear date/time semantics: DateOnly for dates, TimeOnly for times
  • Efficient parsing: IParsable<TSelf> and ISpanParsable<TSelf> let us share parsing logic and avoid allocations in hot paths
  • Practical APIs: C# 14 extension members let us hang useful behavior (like IsUrgent and ToManifestLine) right off our domain types.

Think of these features as guardrails: they let the compiler catch mistakes that used to be runtime bugs — and they do it with zero or negligible runtime overhead.

Thanks for reading, happy holidays, and most importantly: happy parsing! 🎅

ASP.NET CoreCSharp