Type-Safe Collections in C#: How NonEmptyList Eliminates Runtime Exceptions
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
| Issue | Why it’s a problem |
|---|---|
| Silent failures | Returning a default masks the underlying issue |
| Code duplication | Empty checks are scattered throughout the codebase |
| Semantic ambiguity | Is 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:
| Property | Type | Description |
|---|---|---|
| Head | T | First element (guaranteed to exist) |
| Tail | NonEmptyList? | Remaining elements, or null if single |
| Init | NonEmptyList? | 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.