Generic Abstractions
How `IEnumerable<T>` became a universal language for sequences — and how to write your own generic, type-safe pipeline helpers
LINQ is not "a library that works on lists". It's a library that
works on any sequence — arrays, lists, dictionaries, file lines,
network records, custom iterators — through a single generic
interface: IEnumerable<T>.
This unification is what makes the same .Where(...).Select(...)
work for everything from in-memory collections to streaming files.
It is also a fantastic case study in how generics enable
reusable, type-safe abstractions in any language.
The interface that unifies everything
IEnumerable<T> says one thing:
"I can give you a one-direction, one-element-at-a-time iterator over values of type
T."
That's it. No indexing, no length, no mutation. The minimum necessary to walk a sequence.
Because so many data sources can satisfy that contract, almost
everything in .NET implements it. Arrays. List<T>. HashSet<T>.
Dictionary<K,V> (as key-value pairs). string (as char).
File.ReadLines(...). Even your own iterator methods.
This is abstraction as superpower: when you write an algorithm
against IEnumerable<T>, you've written it for all of those at
once.
Generic operators, one definition
Watch how much one type parameter buys you.
One implementation. Three element types. Zero casts. Zero boxing. The C# compiler synthesizes the right concrete code at each call site.
Building a generic MyWhere
To really see how built-in LINQ works, let's reimplement Where.
Two short methods, generic over the element type, and they chain.
This is essentially all built-in Where and Select do.
The real implementations add micro-optimizations (special cases for arrays/lists) but the shape is exactly this.
Constraints: making generics smarter
Sometimes you need to know something about T. C# generic
constraints let you say so.
| Constraint | Meaning |
|---|---|
where T : struct | T is a value type |
where T : class | T is a reference type |
where T : new() | T has a public parameterless constructor |
where T : IComparable<T> | T can be ordered |
where T : SomeBase | T inherits from SomeBase |
where T : notnull | T is non-nullable |
The constraint where T : IComparable<T> is what allows CompareTo
to be called. Without it, the compiler couldn't guarantee that T
supports comparison.
Generic delegates: the lambda type system
LINQ's operator signatures use these tiny, ubiquitous generic delegate types:
| Delegate | Shape | Used for |
|---|---|---|
Func<T, TResult> | T → TResult | selectors, projections |
Func<T, bool> | T → bool | predicates |
Func<T1, T2, TResult> | (T1, T2) → TResult | combining two values |
Action<T> | T → void | side-effecting callbacks |
Every LINQ method's signature you'll ever read is some combination of these. Master them and the entire API surface becomes self-documenting.
Building a generic key-grouped histogram
A satisfying use of constraints + generics — count by an arbitrary key extracted from any sequence.
Take a moment to appreciate that the same Histogram<T, TKey>
method ran with T = string, TKey = int and T = int, TKey = string
without modification. That's the payoff of writing against the
generic abstraction.
Why this matters
Generic abstractions are one of the great inventions of strongly typed languages. They let you write each piece of logic once, preserve type safety at every call site, and grow a library that scales as the program grows.
LINQ is the showcase: dozens of operators, each generic, each working on every sequence type, every projection type — all from a single ten-line interface.
When you call new[] { "a", "b", "c" }.MyWhere(s => s.Length == 1), where does the type T come from?
It defaults to object and gets cast back to string later.
The lambda parameter s is declared as string, which is how T is decided.
The compiler infers T = string from the source array's element type, then specializes MyWhere for string.
T is resolved at runtime by reflection.