Custom Extension Methods for C# LINQ Queries

Custom extension methods in C# allow developers to extend LINQ’s querying capabilities, improve code readability, and encapsulate reusable data-transformation logic. This article explains how to create your own LINQ operators targeting IEnumerable<T>, detailing the necessary syntax, the role of static classes, and how to maintain deferred execution using the yield return keyword.

Prerequisites for Creating an Extension Method

To extend LINQ, your method must satisfy three fundamental C# rules:

  1. It must be defined inside a non-nested, non-generic static class.
  2. The method itself must be declared as static.
  3. The first parameter must include the this keyword, specifying the type being extended (typically IEnumerable<T> for LINQ queries).

Implementing Deferred Execution with yield return

Standard LINQ operators like Where and Select use deferred execution, meaning the query does not execute until the data is enumerated (e.g., inside a foreach loop or by calling .ToList()). Custom LINQ methods should mirror this behavior using yield return.

Here is an implementation of a custom operator called TakeEvery, which returns every N-th element from a sequence:

using System;
using System.Collections.Generic;

public static class LinqExtensions
{
    public static IEnumerable<T> TakeEvery<T>(this IEnumerable<T> source, int step)
    {
        if (source == null)
        {
            throw new ArgumentNullException(nameof(source));
        }

        if (step <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(step), "Step must be greater than zero.");
        }

        return Iterator();

        // Local function ensures argument validation happens immediately,
        // while enumeration remains deferred.
        IEnumerable<T> Iterator()
        {
            int index = 0;
            foreach (T item in source)
            {
                if (index % step == 0)
                {
                    yield return item;
                }
                index++;
            }
        }
    }
}

Creating Conditional Query Extensions

Another common use case is conditional filtering, which avoids breaking up fluent LINQ query chains with external if statements. The following WhereIf method applies a filter only when a specified condition is true:

using System;
using System.Collections.Generic;
using System.Linq;

public static class QueryableExtensions
{
    public static IEnumerable<T> WhereIf<T>(
        this IEnumerable<T> source, 
        bool condition, 
        Func<T, bool> predicate)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (predicate == null) throw new ArgumentNullException(nameof(predicate));

        return condition ? source.Where(predicate) : source;
    }
}

Using the Custom Methods in Queries

Once defined within an imported namespace, custom methods seamlessly integrate with existing LINQ operators:

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 20);
        bool filterOnlyLarge = true;

        var result = numbers
            .WhereIf(filterOnlyLarge, n => n > 5)
            .TakeEvery(2)
            .ToList();

        Console.WriteLine(string.Join(", ", result));
        // Output: 6, 8, 10, 12, 14, 16, 18, 20
    }
}

Supporting IQueryable<T> for Database Providers

If you need to execute custom methods against database contexts (such as Entity Framework Core), extend IQueryable<T> instead of IEnumerable<T>. This requires manipulating expression trees so the underlying provider can translate your logic into SQL:

using System;
using System.Linq;
using System.Linq.Expressions;

public static class DatabaseLinqExtensions
{
    public static IQueryable<T> WhereIf<T>(
        this IQueryable<T> source, 
        bool condition, 
        Expression<Func<T, bool>> predicate)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (predicate == null) throw new ArgumentNullException(nameof(predicate));

        return condition ? source.Where(predicate) : source;
    }
}

Best Practices

  • Separate Validation from Iteration: Throw argument exceptions immediately before yielding execution to avoid delayed runtime failures.
  • Avoid Premature Materialization: Do not call .ToList() or .ToArray() inside intermediate LINQ operators; stream the data using yield return or return chained IEnumerable<T> references.
  • Match Namespaces: Place generic LINQ extensions either in System.Linq or a root utility namespace so they are readily available without requiring niche using directives.