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

Published: (January 1, 2026 at 07:16 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Traditional defensive programming

public async Task CalculateAverageOrderValueAsync(int customerId)
{
    var orders = await _repository.GetOrdersAsync(customerId);
    return orders.Average(o => o.Total); // Throws if orders is empty
}
if (orders.Any())
{
    return orders.Average(o => o.Total);
}
return 0m; // What does zero mean here? No orders? An error?

Drawbacks

IssueWhy it’s a problem
Silent failuresReturning a default masks the underlying issue
Code duplicationEmpty checks are scattered throughout the codebase
Semantic ambiguityIs 0 a valid average or an error state?

A type‑safe solution: NonEmptyList

What if we could express “this collection must have at least one element” directly in our type signatures? That’s exactly what NonEmptyList provides.

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

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

dotnet add package Masterly.NonEmptyList

Compatible with .NET 6.0 and .NET 8.0.

NonEmptyList draws inspiration from functional languages (e.g., Scala’s List). It provides three fundamental traversal properties:

PropertyTypeDescription
HeadTFirst element (guaranteed to exist)
TailNonEmptyList?Remaining elements, or null if single
InitNonEmptyList?All elements except the last, or null if single
var numbers = new NonEmptyList(1, 2, 3, 4, 5);

var head = numbers.Head;   // 1
var tail = numbers.Tail;   // NonEmptyList { 2, 3, 4, 5 }
var init = numbers.Init;   // NonEmptyList { 1, 2, 3, 4 }
var last = numbers.Last;   // 5

Pattern matching

var (first, rest) = numbers;
// first = 1, rest = NonEmptyList { 2, 3, 4, 5 }

Transformations while preserving the non‑empty guarantee

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

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

// Flatten nested collections
NonEmptyList<Item> allItems = orders.FlatMap(o => o.Items);

Aggregation

Traditional LINQ requires a seed because the collection might be empty:

// Throws on empty collection
decimal total = orders.Aggregate(0m, (sum, o) => sum + o.Total);

With NonEmptyList you can use Reduce without a seed:

// Always safe – collection is guaranteed non‑empty
decimal total = orders.Reduce((sum, o) => sum + o.Total);

When the result type differs from the element type:

var summary = orders.Fold(
    new OrderSummary(),
    (summary, order) => summary.AddOrder(order)
);

Partitioning & grouping

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

var byStatus = orders.GroupByNonEmpty(o => o.Status);
// Result: Dictionary<Status, NonEmptyList<Order>>

Explicit handling of single‑ vs. multi‑element cases

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"
    );

This pattern shines in recursive algorithms and batch‑processing logic.


Async support

NonEmptyList offers both sequential and parallel async operations:

var enrichedOrders = await orders.MapAsync(
    async order => await _enrichmentService.EnrichAsync(order)
);

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

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

Immutability

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

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

// Original unchanged
Console.WriteLine(immutable.Count);   // 3

Conversion between mutable and immutable variants:

var immutable = mutableList.ToImmutable();
var mutable   = immutableList.ToMutable();

Serialization

Built‑in System.Text.Json converters handle (de)serialization automatically:

var orders = new NonEmptyList(order1, order2);

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

Deserialization enforces the non‑empty constraint:

// Throws JsonException with a descriptive message
JsonSerializer.Deserialize<NonEmptyList<Order>>("[]");

Entity Framework Core integration

A dedicated package adds database persistence support:

dotnet add package Masterly.NonEmptyList.EntityFrameworkCore
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Store as JSON column
    modelBuilder.Entity<Order>()
        .Property(p => p.Tags)
        .HasNonEmptyListConversion();

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

Example service using the type

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

        // Continue only if every order is valid
        if (validationResults.Any(r => !r.IsSuccess))
            return ProcessingResult.Failure("One or more orders are invalid.");

        // Process the batch
        var processingResults = await orders.MapParallelAsync(
            async order => await _processor.ProcessAsync(order, cancellationToken),
            maxDegreeOfParallelism: 8,
            cancellationToken);

        return ProcessingResult.Success(processingResults);
    }
}

Summary

  • Compile‑time guarantee that a collection is never empty.
  • Eliminates a whole class of runtime exceptions (InvalidOperationException, NullReferenceException).
  • Rich functional API (Map, FlatMap, Reduce, Fold, Partition, async variants, etc.).
  • Immutable & mutable variants, seamless conversion.
  • First‑class support for JSON serialization and EF Core persistence.

Add the package, replace ordinary IEnumerable/List where a non‑empty guarantee is required, and let the compiler enforce your business rules. 🚀


Code Example

elAsync(
    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 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);
}

Ideal Use Cases

  • Domain models where empty collections are invalid (e.g., 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 an empty state.
  • Performance is critical and allocation overhead matters.

Resources

  • GitHub Repository
  • Documentation Wiki
  • NuGet Package
  • EF Core Package

Summary

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 ⭐️ on GitHub.

Back to Blog

Related posts

Read more »

MiniScript Road Map for 2026

2026 Outlook With 2025 coming to a close, it’s time to look ahead to 2026! MiniScript is now eight years old. Many programming languages really come into their...