Skip to content

Add Join/LeftJoin/RightJoin overloads without a result selector, returning tuples #120596

Description

@Thaina

Background and motivation

For simplicity of Join it should just return (TOuter,TInner) instead of the need for resultSelector

API Proposal

namespace System.Linq;

public static class Enumerable
{
    public static IEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static class Queryable
{
    public static IQueryable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IQueryable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IQueryable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static class AsyncEnumerable
{
    public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

API Usage

var keys = new[] { "x" ,"y" ,"z" };
var dict = new Dictionary<int,string>();

foreach (var (s,pair) in keys.Join(dict,(key) => key,(pair) => pair.Value))
    Console.WriteLine(s + " : " pair.Key);

Alternative Designs

Without this it need to make another lambda just for return tuple

foreach (var (s,pair) in keys.Join(dict,(key) => key,(pair) => pair.Value,(outer,inner) => (outer,inner)))
    Console.WriteLine(s + " : " pair.Key);

Also since the key might be calculated maybe the return should include key too?

public static partial class Enumerable
{
    public static IEnumerable<(TKey,TOuter,TInner)> Join<TOuter, TInner, TKey>(this IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector);
}

Risks

No response

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Oct 10, 2025
  2. MihuBot commented on Oct 10, 2025

    @MihuBot

    I'm a bot. Here are possible related and/or duplicate issues (I may be wrong):

  3. eiriktsarpalis commented on Oct 10, 2025

    @eiriktsarpalis
    Member

    Adding an overload that doesn't require a resultSelector seems doable, although I'm not sure what our design guidance is for methods in the same group having different return types.

    Shouldn't this also be returning the key, aka IEnumerable<(TKey, TOuter,TInner)>?

    cc @bartonjs @roji

  4. timcassell commented on Oct 12, 2025

    @timcassell

    It should not return the TKey, the existing resultSelector only passes TOuter and TInner. If you were to return the key, it would have to return TKey innerKey and TKey outerKey, and it's not really useful since you can just get the keys again from the TOuter and TInner items.

  5. roji commented on Oct 16, 2025

    @roji
    Member

    I'm not sure what our design guidance is for methods in the same group having different return types.

    FWIW I'm also not sure we have public APIs returning tuples, though I agree that the proposed Join overloads would be useful; tons of code out there projects to an anonymous type via the result selector: x.Join(y, x => x.Id, y => y.Id, (x, y) => new { X = x, Y = y }); these usages would be simplified, with the added bonus of the returned tuple being deconstructible.

  6. timcassell commented on Oct 16, 2025

    @timcassell

    There is precedent with the Zip extension. The original used a resultSelector, then overloads were later added that return tuple.

  7. roji commented on Oct 17, 2025

    @roji
    Member

    There is precedent with the Zip extension. The original used a resultSelector, then overloads were later added that return tuple.

    Good point. Yeah, Join is conceptually very similar to Zip - both correlate items from two streams and return those items (or a projection thereof), the difference is in the correlation method (Join is key-based, Zip is position-based).

    So I'd say this indeed makes sense and would make a nice quality-of-life improvement.

  8. added
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 17, 2025
  9. added this to the 11.0.0 milestone on Oct 17, 2025
  10. 10 remaining items

  11. manandre commented on Nov 24, 2025

    @manandre
    Contributor

    @roji @eiriktsarpalis For consistency, similar overloads should be added to System.Linq.AsyncEnumerable, recently added in .NET 10 (#79782)
    Could we add them to the current issue or do we have to create a new one?

  12. eiriktsarpalis commented on Nov 25, 2025

    @eiriktsarpalis
    Member

    Good callout, similar to IQueryable we should ensure IAE also has parity.

  13. manandre commented on Nov 25, 2025

    @manandre
    Contributor

    API Proposal (IAsyncEnumerable)

    namespace System.Linq;
    
    public static class AsyncEnumerable
    {
        public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer, 
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
            Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    
        public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer,
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, TKey> outerKeySelector,
            Func<TInner, TKey> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    
        public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer, 
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
            Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    
        public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer,
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, TKey> outerKeySelector,
            Func<TInner, TKey> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    
        public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer, 
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
            Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    
        public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
            this IAsyncEnumerable<TOuter> outer,
            IAsyncEnumerable<TInner> inner,
            Func<TOuter, TKey> outerKeySelector,
            Func<TInner, TKey> innerKeySelector,
            IEqualityComparer<TKey>? comparer = null);
    }
    ```
  14. eiriktsarpalis commented on Nov 26, 2025

    @eiriktsarpalis
    Member

    Updated.

  15. added 2 commits that reference this issue on Mar 29, 2026
    ffbb69a
    3a8b439
  16. changed the title [-][API Proposal]: Linq Join return tuple similar to Zip[/-] [+]Add Join/LeftJoin/RightJoin overloads without a result selector, returning tuples[/+] on Apr 4, 2026
  17. added a commit that references this issue on Apr 6, 2026
    2916d73
  18. added a commit that references this issue on Apr 8, 2026
    dc8035a
  19. added a commit that references this issue on Apr 9, 2026
    a2510e8
  20. locked and limited conversation to collaborators on May 7, 2026
  21. added a commit that references this issue on Jul 15, 2026
    3157913
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions