DEV Community

Cover image for Type-Safe Collections in C#: How NonEmptyList Eliminates Runtime Exceptions
Ahmad Al-Freihat
Ahmad Al-Freihat

Posted on

Type-Safe Collections in C#: How NonEmptyList Eliminates Runtime Exceptions

The Problem with Empty Collections

Every C# developer has encountered this scenario: a method returns a collection that should contain data, but at runtime, it's empty. The result? InvalidOperationException, null reference errors, or subtle bugs that only surface in production.

public async Task<decimal> CalculateAverageOrderValueAsync(int customerId)
{
    var orders = await _repository.GetOrdersAsync(customerId);
    return orders.Average(o => o.Total); // Throws if orders is empty
}
Enter fullscreen mode Exit fullscreen mode

The traditional solution involves defensive programming:

if (orders.Any())
{
    return orders.Average(o => o.Total);
}
return 0m; // What does zero mean here? No orders? An error?
Enter fullscreen mode Exit fullscreen mode

This approach has significant drawbacks:

  • Silent failures: Returning default values masks underlying issues
  • Code duplication: Empty checks scattered throughout the codebase
  • Semantic ambiguity: Is zero a valid average or an error state?

A Type-System Solution

What if we could express "this collection must have at least one element" directly in our type signatures? This is precisely what NonEmptyList provides.

public async Task<decimal> CalculateAverageOrderValueAsync(
    NonEmptyList<Order> orders)  // Guaranteed non-empty at compile time
{
    return orders.Average(o => o.Total); // Always safe
}
Enter fullscreen mode Exit fullscreen mode

The compiler now enforces our business rule. Callers must provide a non-empty collection, shifting validation from runtime to compile time.

Installation

dotnet add package Masterly.NonEmptyList
Enter fullscreen mode Exit fullscreen mode

Compatible with .NET 6.0 and .NET 8.0.

Core API Design

NonEmptyList draws inspiration from functional programming languages, particularly Scala's List type. It provides three fundamental properties for traversing collections:

Property Type Description
Head T First element (guaranteed to exist)
Tail NonEmptyList<T>? Remaining elements, or null if single
Init NonEmptyList<T>? All elements except last, or null if single
var numbers = new NonEmptyList<int>(1, 2, 3, 4, 5);

var head = numbers.Head;  // 1
var tail = numbers.Tail;  // NonEmptyList<int> { 2, 3, 4, 5 }
var init = numbers.Init;  // NonEmptyList<int> { 1, 2, 3, 4 }
var last = numbers.Last;  // 5
Enter fullscreen mode Exit fullscreen mode

Deconstruction Support

C# pattern matching integrates naturally:

var (first, rest) = numbers;
// first = 1, rest = NonEmptyList<int> { 2, 3, 4, 5 }
Enter fullscreen mode Exit fullscreen mode

Functional Transformations

Map and FlatMap

Transform elements while preserving the non-empty guarantee:

var orders = new NonEmptyList<Order>(order1, order2, order3);

// Transform to DTOs
NonEmptyList<OrderDto> dtos = orders.Map(o => new OrderDto(o));

// Flatten nested collections
NonEmptyList<LineItem> allItems = orders.FlatMap(o => o.Items);
Enter fullscreen mode Exit fullscreen mode

Reduce: Aggregation Without Edge Cases

Traditional Aggregate requires handling empty collections:

// Standard LINQ - throws on empty collection
decimal total = orders.Aggregate(0m, (sum, o) => sum + o.Total);
Enter fullscreen mode Exit fullscreen mode

With NonEmptyList, Reduce requires no seed value because the collection is guaranteed non-empty:

// NonEmptyList - always safe, no seed required
decimal total = orders.Reduce((sum, o) => sum + o.Total);
Enter fullscreen mode Exit fullscreen mode

This eliminates an entire category of runtime errors.

Fold for Type Transformations

When the result type differs from the element type:

var summary = orders.Fold(
    new OrderSummary(),
    (summary, order) => summary.AddOrder(order)
);
Enter fullscreen mode Exit fullscreen mode

Partition and GroupBy

Separate collections based on predicates:

var (highValue, standard) = orders.Partition(o => o.Total > 1000m);

var byStatus = orders.GroupByNonEmpty(o => o.Status);
// Dictionary<OrderStatus, NonEmptyList<Order>>
Enter fullscreen mode Exit fullscreen mode

Pattern Matching

Handle single-element and multi-element cases explicitly:

string ProcessOrders(NonEmptyList<Order> orders) =>
    orders.Match(
        single: order =>
            $"Processing single order: {order.Id}",
        multiple: (first, remaining) =>
            $"Processing batch: {first.Id} and {remaining.Count} more"
    );
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly useful for recursive algorithms and batch processing logic.

Asynchronous Operations

Modern applications require async support. NonEmptyList provides both sequential and parallel async operations:

Sequential Processing

var enrichedOrders = await orders.MapAsync(
    async order => await _enrichmentService.EnrichAsync(order)
);
Enter fullscreen mode Exit fullscreen mode

Parallel Processing with Controlled Concurrency

var results = await orders.MapParallelAsync(
    async order => await _processor.ProcessAsync(order),
    maxDegreeOfParallelism: 4
);
Enter fullscreen mode Exit fullscreen mode

Async Aggregation

var totalRevenue = await orders.FoldAsync(
    0m,
    async (sum, order) => sum + await _calculator.CalculateRevenueAsync(order)
);
Enter fullscreen mode Exit fullscreen mode

Immutable Variant

For concurrent scenarios or when immutability is required:

var immutable = new ImmutableNonEmptyList<int>(1, 2, 3);

// All operations return new instances
var appended = immutable.Append(4);      // New list: [1, 2, 3, 4]
var prepended = immutable.Prepend(0);    // New list: [0, 1, 2, 3]

// Original unchanged
Console.WriteLine(immutable.Count);      // Still 3
Enter fullscreen mode Exit fullscreen mode

Convert between variants as needed:

var immutable = mutableList.ToImmutable();
var mutable = immutableList.ToMutable();
Enter fullscreen mode Exit fullscreen mode

Serialization

JSON Support

Built-in System.Text.Json converters handle serialization automatically:

var orders = new NonEmptyList<Order>(order1, order2);

string json = JsonSerializer.Serialize(orders);
var restored = JsonSerializer.Deserialize<NonEmptyList<Order>>(json);
Enter fullscreen mode Exit fullscreen mode

Deserialization enforces the non-empty constraint:

// Throws JsonException with descriptive message
JsonSerializer.Deserialize<NonEmptyList<int>>("[]");
Enter fullscreen mode Exit fullscreen mode

Entity Framework Core Integration

A dedicated package provides database persistence support:

dotnet add package Masterly.NonEmptyList.EntityFrameworkCore
Enter fullscreen mode Exit fullscreen mode

Configure properties and relationships:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Store as JSON column
    modelBuilder.Entity<Product>()
        .Property(p => p.Tags)
        .HasNonEmptyListConversion();

    // Configure relationships with non-empty constraint
    modelBuilder.Entity<Order>()
        .HasNonEmptyManyWithOne(
            o => o.LineItems,
            li => li.Order,
            li => li.OrderId
        );
}
Enter fullscreen mode Exit fullscreen mode

Practical Example: Order Processing Pipeline

public class OrderProcessingService
{
    public async Task<ProcessingResult> ProcessBatchAsync(
        NonEmptyList<Order> orders,
        CancellationToken cancellationToken = default)
    {
        // Validate all orders in parallel
        var validationResults = await orders.MapParallelAsync(
            order => _validator.ValidateAsync(order, cancellationToken),
            maxDegreeOfParallelism: 8
        );

        // Partition by validation result
        var (valid, invalid) = orders
            .Zip(validationResults)
            .Partition(pair => pair.Item2.IsValid);

        if (invalid.Any())
        {
            _logger.LogWarning("Found {Count} invalid orders", invalid.Count);
        }

        // Process valid orders
        var processed = valid switch
        {
            null => new ProcessingResult(0, 0m),
            var validOrders => await ProcessValidOrdersAsync(
                validOrders.Map(pair => pair.Item1),
                cancellationToken
            )
        };

        return processed;
    }

    private async Task<ProcessingResult> ProcessValidOrdersAsync(
        NonEmptyList<Order> orders,
        CancellationToken cancellationToken)
    {
        // Calculate totals - Reduce is safe, no empty check needed
        var totalRevenue = orders.Reduce((sum, o) => sum + o.Total);

        // Process with pattern matching
        await orders.Match(
            single: async order =>
                await _processor.ProcessSingleAsync(order, cancellationToken),
            multiple: async (priority, remaining) =>
            {
                await _processor.ProcessPriorityAsync(priority, cancellationToken);
                await remaining.ForEachAsync(
                    order => _processor.ProcessAsync(order, cancellationToken)
                );
            }
        );

        return new ProcessingResult(orders.Count, totalRevenue);
    }
}
Enter fullscreen mode Exit fullscreen mode

When to Use NonEmptyList

Ideal use cases:

  • Domain models where empty collections are invalid (orders must have line items)
  • API contracts that require at least one element
  • Aggregation operations where empty input is an error
  • Functional programming patterns (head/tail recursion)

Consider alternatives when:

  • Empty collections are valid in your domain
  • You need to gradually build collections from empty state
  • Performance is critical and allocation overhead matters

Resources


NonEmptyList brings a well-established functional programming pattern to C#, enabling developers to express domain constraints directly in the type system. By shifting validation from runtime to compile time, we eliminate entire categories of bugs while making code more expressive and self-documenting.

If this library helps your project, consider giving it a star on GitHub.

Top comments (0)