dotnet / dotnet/runtime

Proposal: Extension interfaces implementation through witness types

Open
#133,245 5 comments 13 reactions 0 assignees View on GitHub
area-TypeSystem-coreclr untriaged
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

## Background

Today, .NET requires interface implementations to be declared directly on the implementing type. This restriction blocks two recurring patterns:

- **Third-party interface implementations on foreign types:** A library defining `IPrint` cannot make `int`, `string`, or foreign types implement it without wrappers, adapter registries, or dynamic lookups outside the type system.
- **Conditional implementations:** Generic types cannot implement interfaces conditionally based on their type arguments, such as making `List` implement `IDeepEqual>` only when `T` implements `IDeepEqual`.

```csharp
static class DeepEqualityExtensions
{
extension(List self) : IDeepEqual>
where T : IDeepEqual
{
bool IDeepEqual>.DeepEquals(List other)
{
if (self.Count != other.Count)
return false;

for (int i = 0; i < self.Count; i++)
if (!self[i].DeepEquals(other[i]))
return false;

return true;
}
}
}
```

While other languages like Rust support these patterns through type classes or traits, .NET languages currently force developers to abandon interface abstractions entirely.

This proposal lets the runtime resolve extension interface implementations on demand without modifying nominal interface maps. It is inspired by [the C# language proposal](https://github.com/dotnet/csharplang/issues/9319), with changes to the semantics and runtime model. The implementation notes draw on [my CoreCLR prototype](https://github.com/hez2010/runtime/tree/extiface).

Disclaimer: AI was used to assist in writing this proposal and developing the prototype. I have manually reviewed the proposal and verified the implementation and tested the prototype.

## Considerations

There're several ongoing discussions happening in dotnet/csharplang, but a viable runtime execution model is still missing. Appending the interface to the `InterfaceMap` of every matching `MethodTable` simply won't work because it complicates interface map building, inflates generic instantiation costs, and makes failed casts depend on assembly load order, and unaffected code would pay huge performance penalties continuously.

Given this, the design is shaped by several runtime requirements:

**Pay-for-play:** Interface casts and dispatch are among the most performance-sensitive paths in the runtime. The design cannot add branches, lookups, or `MethodTable` bloat to code that does not participate in the feature. Extension metadata is inspected only after nominal resolution fails.

**Object identity:** Managed interface references must remain direct object references, and boxed value types must retain their standard header, `MethodTable*`, and payload layout. Introducing fat pointers, wrapper objects, or extra box fields would ripple across calling conventions, GC scanning, generic sharing, and P/Invoke. `ReferenceEquals` and `GetType()` must continue reporting true identity.

**Generic identity:** Passing witness tokens as additional generic arguments (such as transforming `M()` into `M()`) changes the identity of participating types and leaks implementation choices into public signatures. `List` must remain `List`.

**Stable negative results:** The runtime caches failed casts. If a cast could fail initially and succeed later simply because an unrelated assembly loaded, the negative cast cache would require widespread synchronization and invalidation. Negative results must remain permanently valid.

**Bounded discovery:** Answering whether `T` implements `I` cannot involve scanning all loaded modules or executing module initializers. Discovery must rely strictly on metadata reachable from `T` and `I`.

**Value-type semantics:** Constrained calls to implementations declared on the value type itself preserve in-place mutation without an extra box.

## Design

### The witness relation

In the opening example, `List` is the receiver target and `IDeepEqual>` is the declared contract. A nominal implementation is one provided by the ordinary type declaration or its inherited hierarchy.

Instead of modifying the receiver's nominal interface map, the runtime evaluates an immutable relation on demand:

```text
(exact receiver runtime type, requested interface) -> witness type
```

The witness is a compiler-generated interface type that nominally implements the declared contract and supplies its member implementations. One type definition represents the declaration; a closed witness, with all type arguments supplied, represents a particular application of that declaration.

The receiver object never instantiates the witness. The witness is derived from the `(receiver, interface)` pair and cached by the runtime; it is not stored in object headers, boxed payloads, interface references, or public generic signatures.

Type satisfaction follows a two-tier rule: `T` satisfies `I` if it does so nominally; failing that, it satisfies `I` if the relation produces a valid witness. Nominal implementations always take precedence.

Declarations have no per-instance state, are independent of namespace imports, and define relations that remain fixed for the lifetime of their loaded modules.

### Coherence and candidate discovery

Ownership determines where the runtime finds candidates. Coherence determines whether those candidates yield a unique implementation.

An extension declaration must be in the module defining its target type or its contract interface. The target owner is its outermost type definition, such as `List<>` for `List`. A declaration owned by the target is type-owned; one owned by the contract is interface-owned. Here, "interface-owned" always refers to the contract, even when the receiver target is also an interface.

A class or value-type target is type-owned when both owners are local. Interface targets, array targets, and bare type-parameter targets are always interface-owned. These ownership rules make lookup results stable under unrelated assembly loads.

An interface-owned declaration is valid only if all transitive base interfaces of its contract belong to the same module. For example, suppose module `A` defines `interface IChild : Foreign.IBase` and an interface-owned implementation of `IChild` for a foreign type `T`. Converting that `IChild` reference to `Foreign.IBase` must remain possible, but neither the receiver hierarchy nor `IBase` leads resolution back to module `A`. Type-owned declarations have no such restriction because resolution can find them through the receiver's nominal hierarchy.

Compilers conservatively reject observable overlaps, including those through base interfaces or variance. Multiple paths to the same closed witness count as one implementation. Distinct applicable closed witnesses are ambiguous, even when they come from the same declaration. There is no priority between competing implementations; an ambiguity detected at runtime causes `TypeLoadException`.

### Witness argument inference

Resolution must determine every type argument of the witness. Subject to the receiver-target rules in the following sections, it may combine exact matches from the receiver, invariant arguments of the requested interface, and declared constraints matched against nominal bases or interfaces of types whose arguments are already known.

Determining arguments does not by itself make a candidate applicable: all constraints must hold, and the closed contract must convert to the requested interface. Variance and extension satisfaction may validate known arguments, but cannot supply missing ones. An invoked member's signatures and method type arguments are not inference sources.

For example, the requested interface can provide information absent from the receiver:

```csharp
// ICodec is invariant.
extension(Receiver self) : ICodec { ... }
```

Requesting `ICodec` determines `T = int`. Requiring every argument to occur in the receiver would unnecessarily exclude this case.

Constraints can also expose information already present in a known type:

```csharp
extension(TList self) : ICount
where TList : class, IList
{ ... }
```

For a `List` receiver, its nominal `IList` implementation determines `TElement = int`. If a receiver implements both `IList` and `IList`, both possibilities must be considered; other constraints may distinguish them.

Variance does not provide the same certainty:

```csharp
interface IProducer { T Produce(); }

extension(Receiver self) : IProducer { ... }
```

A request for `IProducer` does not determine `T`: both `IProducer` and `IProducer` can convert to it. The argument must therefore come from another permitted source.

### Bare receiver parameters

A bare target, such as `extension(TReceiver self)`, binds `TReceiver` to the exact receiver type. The parameter must be constrained as a reference type or a non-nullable value type.

For an unsealed class, rebinding that parameter to a derived type must preserve the supplied contracts and their requirements. Receiver occurrences must therefore be covariant in contracts and contravariant in constraint types, with no `new()` requirement on the receiver. For example, a derived class can retain an `IComparable` requirement through contravariance. Inheriting `IEquatable` does not establish the invariant `IEquatable`, and a derived class need not retain a public parameterless constructor.

Value types and sealed classes without array or delegate variance may use exact-type conditions. A named class target can also keep its receiver-specific contract fixed across derived types.

The bare reference form does not apply to `object`, `ValueType`, or `Enum`, whose values can include boxes. Arrays and variant delegates require the same variance-safe contracts and conditions, and cannot supply additional witness arguments through their changing nominal projections. Ordinary class-hierarchy inference, including the `List : IList` example above, remains supported.

### Interface receiver targets

An interface target and its witness arguments must be fully determined from invariant contract arguments and constraints on already known types before receiver satisfaction is checked. Its witness arguments cannot be inferred from the receiver's nominal interface projections. The receiver may satisfy the known target nominally or through another extension.

For example:

```csharp
// Declared in the module defining IFirst:
extension(C self) : IFirst { ... }

// Declared in the module defining ISecond:
extension(IFirst self) : ISecond { ... }
```

Resolving `(C, ISecond)` discovers the second declaration beside `ISecond`, then resolves its known `(C, IFirst)` prerequisite. Both ownership and target satisfaction matter: the first makes the declaration discoverable, and the second allows the chain to apply. A chain with no independent starting implementation establishes nothing.

Likewise, `extension(IFirst self) : ISecond` can obtain `T` from an invariant request for `ISecond`. A non-generic destination cannot recover `T` merely because the static source type was `IFirst`: the actual object may implement it only through an extension.

These declarations also allow an interface type argument to satisfy another interface through an extension. Bare receiver declarations cannot establish such satisfaction: conditions proved for the interface type itself need not hold for every object it can reference, including boxed values. Interface-target bodies use ordinary reference and boxing semantics.

Array targets and variant delegate targets follow the same order: determine the complete target type, then check ordinary assignability. Array targets are interface-owned; delegate targets retain the ordinary ownership rule.

### Consistency across interface views

An ordinary interface reference contains no history recording which view selected the implementation. For a single applied declaration, every base or variance view relying on that extension must therefore independently recover the same closed witness.

For example, consider a base interface that drops a type argument:

```csharp
interface IErased { }
interface ITagged : IErased { }

extension(Receiver self) : ITagged { }
```

A request for `ITagged` determines `T = int`, but a later request for `IErased` cannot recover it. This binding is invalid unless `Receiver` already supplies `IErased` nominally.

Unrelated interfaces may be supplied by different declarations, and nominal implementations retain precedence. Compilers reject observable violations; if an otherwise applicable binding fails this rule at runtime, resolution throws `TypeLoadException` before publishing a result.

### Recursive resolution

Checking a known interface target or an applicability constraint can require resolving another pair. For example, `List` may satisfy `IPrint` when `A` satisfies `IPrint`. A cycle alone cannot satisfy a constraint. When satisfaction is established independently of that cycle, affected candidates are evaluated again before selecting a witness.

Resolution must have finite limits on nesting and evaluated obligations. Exceeding a limit throws `TypeLoadException` identifying incomplete resolution; it must not report or cache absence or a unique witness. Finding one candidate does not establish uniqueness while another candidate remains unresolved. For example, `IChain -> IChain> -> IChain>> -> ...` must terminate with this error rather than exhaust the loader or stack.

Contract well-formedness is a separate check. Consider a self-referential interface:

```csharp
interface ISelf where TSelf : ISelf { }

extension(C self) : ISelf { }
```

After this unconditional declaration is found applicable and unambiguous, the implementation being established may satisfy the contract's own `C : ISelf` requirement. An explicit applicability condition requiring `C : ISelf` still needs independent support. This allowance is confined to validation of the declared contract and its nominal bases; it cannot assume unrelated interfaces merely because the witness also inherits them.

### Lowering and member shape

Each extension declaration lowers to exactly one witness interface definition, generic when needed, marked with a runtime-recognized attribute. For example, an extension that prints a `List` when `T` satisfies `IPrint` has the following conceptual lowering:

```csharp
[CompilerGenerated]
[ExtensionInterfaceImplementation]
private interface __ListPrint : IPrint
where T : IPrint
{
// Adapter member: reuses existing default interface method (DIM) resolution
void IPrint.Print() => __Print((List)(object)this);

// Canonical static body: accepts the receiver explicitly
private static void __Print(List self)
{
foreach (T item in self)
item.Print();
}
}
```

Emitting an interface avoids altering the GC, debugger, or object layout, as no object instance ever has the witness as its `MethodTable` (matching `IDynamicInterfaceCastable`).

Each user-provided instance implementation emits two relevant forms:

- An **adapter member**, which is an ordinary interface method enabling boxed and reference-type dispatch through standard default interface method infrastructure.
- A **canonical static body**, which accepts the receiver explicitly (`Target` for reference types, `ref Target` or `in Target` for value types). For an implementation declared on the value receiver itself, this preserves mutation without an extra box during constrained calls.

Static interface members only require the static body, as they have no receiver instance.

For each explicitly implemented instance member, the compiler emits at most one adapter and one canonical body. Closing the witness reuses these definitions.

When a declaration is generic over its receiver, the witness carries that receiver type parameter. For example:

```cs
extension(TReceiver self) : IIncrement
where TReceiver : struct, IIncrementableStorage
{
void IIncrement.Increment()
{
self.Value++;
}
}
```

The compiler emits one generic witness definition:

```cs
[CompilerGenerated]
[ExtensionInterfaceImplementation]
internal interface __IncrementImpl : IIncrement
where TReceiver : struct, IIncrementableStorage
{
// Generic boxed/interface-dispatch adapter.
void IIncrement.Increment()
{
ref TReceiver receiver =
ref Unsafe.Unbox((object)this);

__Body(ref receiver);
}

// One canonical generic IL body.
private static void __Body(ref TReceiver receiver)
{
receiver.Value++;
}
}
```

Declaration type parameters belong to the witness type so that different applications retain distinct closed implementation identities. A non-generic witness with a generic static method would lose that identity:

```cs
interface __IncrementImpl
{
static void Body(ref TReceiver receiver);
}
```

Every receiver would share the implementation type `__IncrementImpl`, instead of identifying its application through a closed type such as `__IncrementImpl`.

### Metadata representation and module indexing

The compiler records declarations and member mappings in two logical tables in the defining module. Their physical encoding can use existing ECMA-335 metadata mechanisms:

1. `ExtensionInterfaceImpl`: Records each declaration mapping with an `Owner` token (the lookup anchor), an `Implementation` token (the witness interface), `Target` and `Interface` blob signatures evaluated within the witness generic context, and an ownership flag (`TypeOwned` or `InterfaceOwned`). Rows are sorted by `Owner`.
2. `ExtensionInterfaceMethodImpl`: Maps each contract member (`Declaration`) to its canonical static body (`Body`) on the witness (`Implementation`). Instance members have an extra receiver parameter.

Validation requires a marked witness interface in the declaring module, a complete and unique binding for all witness type parameters under the inference rules above, and valid implementations of the declared contracts. Interface-owned declarations must satisfy the base-interface visibility restriction. The marker alone does not establish an implementation.

The information can be encoded in a compiler-reserved module-level custom attribute containing a versioned binary manifest. Such a manifest can hold an owner-sorted index, declarations with their target and contract CLI signature blobs, and mappings to canonical static bodies. A separate metadata stream is another possible encoding. My prototype uses custom attributes to avoid a metadata format change.

Regardless of encoding, only modules containing extension declarations allocate an extension index. The runtime builds this index lazily, mapping owners to their declaration rows. Modules without extension metadata allocate no extension index.

### Runtime markers

To make this feature pay-for-play, the runtime must never inspect extension metadata or query module indices during ordinary nominal interface operations. Instead, participating types and interfaces are identified using two opt-in marker bits on their `MethodTable`:

- `MayHaveTypeOwnedExtensionImplementations`: Set on types that define or inherit type-owned extension declarations, with propagation through the base-class hierarchy.
- `MayHaveInterfaceOwnedExtensionImplementations`: Set on contract interface definitions that carry interface-owned declarations (and on base interfaces defined in the same module).

The type-owned marker can fold directly into the existing `enum_flag_NonTrivialInterfaceCast` mask. `CastHelpers.IsInstanceOfInterface` already checks this mask after the nominal interface-map scan misses, so successful nominal casts need no additional branch. Similarly, for nonvariant interfaces, JIT helper selection routes to extension-aware helpers only when the target interface carries the interface-owned marker. Types and interfaces lacking these markers bypass extension logic entirely.

### Pair resolution algorithm

When a nominal cast, dispatch, or constraint check misses and the marker bits indicate participation, the runtime evaluates `(receiver, interface)` using the preceding rules:

1. Collect candidates from the relevant module indices. Type-owned declarations are anchored to the receiver's type or its nominal base classes. Interface-owned declarations are indexed by the requested interface definition, including declarations for its same-module derived contracts.
2. Infer witness arguments, check the receiver target and explicit applicability constraints, and check consistency across interface views. Recursive dependencies follow the resolution rules above.
3. Finish evaluating the candidate set. For a unique witness, validate contract well-formedness before publishing it. Distinct applicable witnesses are ambiguous; an unresolved candidate prevents publishing a unique witness or absence.

### Exact pair cache

Completed resolution results can be cached by exact receiver and requested interface, including absence and ambiguity. The ownership rule makes these results stable under unrelated assembly loads. Caching must preserve collectible assembly unloading

### Runtime operations

Casts, member calls, delegates, and reflection must all resolve a given receiver/interface pair to the same witness. Extension interface implementation participates in the following runtime operations:

**Casts (`isinst`, `castclass`):** Nominal lookup executes first. On failure, marker bits are checked and the pair is resolved. The original reference is returned directly, preserving `ReferenceEquals` and `GetType()`. Value types undergo a standard single box with no secondary wrapper. Array stores and other semantic casts use the same extension-aware assignability check.

**Interface dispatch:** On dispatch cache misses, pair resolution runs after nominal failure. The slot resolves against the witness adapter, and the resulting entry point is stored in the standard dispatch cache. Extension resolution explicitly precedes `IDynamicInterfaceCastable` and COM fallback so dispatch can consistently re-derive the implementation from the receiver type and interface alone.

**Generic constraints:** Interface constraints accept extension satisfaction after nominal checks fail. Calls through those constraints use the selected witness through ordinary generic dispatch machinery, preserving the normal shared-generic hot path. Obligations containing type parameters may be proved using the caller's declared constraints. Such proofs do not change open-type reflection assignability; closed applications still resolve their own witnesses.

**Value types:** Constrained calls to implementations declared on the value receiver itself forward managed pointers (`ref T` or `in T`) directly into the canonical static body, avoiding dispatch boxing and preserving in-place mutation. Calling through boxed interface references mutates the box payload as in normal boxed dispatch. `Nullable` is excluded because boxing erases its wrapper; byref-like receivers are outside the scope of this proposal and can be supported in the future.

**Reflection:** Pair-based queries (`IsAssignableFrom`, `IsAssignableTo`, `GetInterfaceMap`) are extension-aware. `GetInterfaceMap` maps contract members to the witness adapter methods. Reverse enumeration (`Type.GetInterfaces()` and `TypeInfo.ImplementedInterfaces`) remains nominal-only, because discovering every interface-owned implementation across arbitrary unreferenced assemblies in an open-world model is impossible without a global registry or load-order dependencies.

### New reflection API

In addition to making existing pair-based queries extension-aware, introduce an API that reports the selected witness:

```cs
Type? GetExtensionInterfaceImplementation(Type receiverType, Type interfaceType);
```

For example, `GetExtensionInterfaceImplementation(typeof(List), typeof(IPrint))` returns `__ListPrint` when the list-printing extension is selected.

For closed runtime types, this API returns the effective witness, or `null` if no extension applies or a nominal implementation wins. Resolution errors, including ambiguity and exceeding a resolution limit, propagate as `TypeLoadException`. Returning a witness does not make the receiver assignable to that witness interface itself.

### Optimizations and deployment

When the exact receiver type is statically known, the JIT may devirtualize dispatch, inline static bodies, and forward value-type managed references directly.

AOT compilation and trimming must preserve observable resolution outcomes for the retained program: nominal precedence, witness identity, absence, and ambiguity. A declaration is not unused merely because its body is never called; casts, reflection, generic constraints, or a competing candidate can make it relevant. Removing bodies and removing resolution metadata require separate proofs.

ReadyToRun may precompute a result only when its versioning guarantees protect the relevant type and declaration dependencies. Otherwise casts, dispatch, and constraints must retain runtime lookup and extension-sensitive participation checks.

## Alternatives

**Mutating nominal interface maps:** While this makes the feature appear nominal, it substantially complicates interface map building, inflates generic instantiation costs, requires eager propagation to derived and array types, and invalidates negative cast caches upon assembly loading. Unaffected code pays these costs unconditionally.

**Wrapper objects:** Wrapping preserves the nominal type system but allocates wrapper instances, breaks `GetType()` and `ReferenceEquals`, introduces alias inconsistencies, double-boxes value types, and cannot express implementation identity in generics.

**Fat interface references (pointer + witness):** Carrying object and witness pointers together would allow lexically scoped implementations, but at the cost of fundamentally breaking the managed ABI, calling conventions, stack layout, GC pointer tracking, and P/Invoke across every interface in the runtime.

**Hidden witness generic arguments (`M`):** The classical type-class approach is sound, but altering generic signatures changes method and type identities, leaks implementation choices into public APIs, and requires widespread compiler and runtime ABI adjustments. Coherence makes this extra argument unnecessary.

**Compiler-only call rewriting:** Lowering calls to static extension methods handles simple cases, but fails for casts from `object`, interface-typed storage, cross-assembly constraints, reflection, interface arrays, and static abstract interface members.

**Global runtime registry:** Populating a process-wide registry via module initializers creates load-order dependencies, prevents negative cast caching, increases startup overhead, and degrades trimming and AOT compatibility.

**Direct `IDynamicInterfaceCastable` usage:** Reusing this mechanism conceptually is valuable, but exposing it as the primary model requires modifying the target type, excludes value types, operates per-instance rather than per-type, and cannot satisfy generic constraints or static abstract interface members.

## Performance study

To verify the performance of this design, we implemented a prototype in CoreCLR and ran a series of benchmarks. See the branch [here](https://github.com/hez2010/runtime/tree/extiface) and the benchmark set [here](https://github.com/hez2010/runtime/tree/extiface/src/tests/Loader/classloader/ExtensionInterface/Benchmarks).
In this prototype all the semantics including object / generic type identities are preserved correctly and tested, see the test cases [here](https://github.com/hez2010/runtime/tree/extiface/src/tests/Loader/classloader/ExtensionInterface).

All benchmarks were run with tiered compilation disabled, and the benchmark code was explicitly opting-out devirtualization and inlining (the exact-devirtualization case allows devirtualization).

### Main branch vs feature branch in ordinary paths

Below is the benchmark that compares nominal operations.

| Method | Id | Mean | Error | StdDev | Ratio | MannWhitney(1%) | Allocated |
| --- | --- | ---: | ---: | ---: | ---: | --- | ---: |
| PositiveCast | main branch | 2.661 ns | 0.0234 ns | 0.0219 ns | 1.00 | Baseline | - |
| PositiveCast | feature branch | 2.655 ns | 0.0117 ns | 0.0103 ns | 1.00 | Same | - |
| | | | | | | | |
| InterfaceOwnedPositiveCast | main branch | 2.476 ns | 0.0141 ns | 0.0132 ns | 1.00 | Baseline | - |
| InterfaceOwnedPositiveCast | feature branch | 2.431 ns | 0.0482 ns | 0.0451 ns | 0.98 | Same | - |
| | | | | | | | |
| NegativeCast | main branch | 2.744 ns | 0.0032 ns | 0.0030 ns | 1.00 | Baseline | - |
| NegativeCast | feature branch | 2.543 ns | 0.0055 ns | 0.0051 ns | 0.93 | Faster | - |
| | | | | | | | |
| ExplicitCast | main branch | 2.681 ns | 0.0210 ns | 0.0196 ns | 1.00 | Baseline | - |
| ExplicitCast | feature branch | 2.630 ns | 0.0515 ns | 0.0482 ns | 0.98 | Same | - |
| | | | | | | | |
| InterfaceDispatch | main branch | 2.920 ns | 0.0072 ns | 0.0068 ns | 1.00 | Baseline | - |
| InterfaceDispatch | feature branch | 2.531 ns | 0.0030 ns | 0.0028 ns | 0.87 | Faster | - |
| | | | | | | | |
| InterfaceOwnedDispatch | main branch | 2.925 ns | 0.0043 ns | 0.0040 ns | 1.00 | Baseline | - |
| InterfaceOwnedDispatch | feature branch | 2.911 ns | 0.0025 ns | 0.0023 ns | 0.99 | Same | - |
| | | | | | | | |
| BaseInterfaceDispatch | main branch | 2.555 ns | 0.0027 ns | 0.0024 ns | 1.00 | Baseline | - |
| BaseInterfaceDispatch | feature branch | 2.532 ns | 0.0045 ns | 0.0042 ns | 0.99 | Same | - |
| | | | | | | | |
| DelegateDispatch | main branch | 2.743 ns | 0.0029 ns | 0.0027 ns | 1.00 | Baseline | - |
| DelegateDispatch | feature branch | 2.542 ns | 0.0043 ns | 0.0040 ns | 0.93 | Faster | - |
| | | | | | | | |
| ArrayStore | main branch | 7.694 ns | 0.0849 ns | 0.0794 ns | 1.00 | Baseline | - |
| ArrayStore | feature branch | 7.550 ns | 0.0954 ns | 0.0892 ns | 0.98 | Same | - |
| | | | | | | | |
| BoxedValueGet | main branch | 3.910 ns | 0.0072 ns | 0.0067 ns | 1.00 | Baseline | - |
| BoxedValueGet | feature branch | 3.860 ns | 0.0049 ns | 0.0046 ns | 0.99 | Faster | - |
| | | | | | | | |
| BoxedValueIncrement | main branch | 6.191 ns | 0.0116 ns | 0.0108 ns | 1.00 | Baseline | - |
| BoxedValueIncrement | feature branch | 6.159 ns | 0.0105 ns | 0.0098 ns | 0.99 | Same | - |
| | | | | | | | |
| ExactDevirtualization | main branch | 2.931 ns | 0.0042 ns | 0.0040 ns | 1.00 | Baseline | - |
| ExactDevirtualization | feature branch | 2.733 ns | 0.0039 ns | 0.0032 ns | 0.93 | Faster | - |
| | | | | | | | |
| ReferenceConstraint | main branch | 4.707 ns | 0.0502 ns | 0.0470 ns | 1.00 | Baseline | - |
| ReferenceConstraint | feature branch | 5.012 ns | 0.0040 ns | 0.0037 ns | 1.06 | Slower | - |
| | | | | | | | |
| ValueConstraint | main branch | 3.109 ns | 0.0039 ns | 0.0037 ns | 1.00 | Baseline | - |
| ValueConstraint | feature branch | 3.111 ns | 0.0026 ns | 0.0025 ns | 1.00 | Same | - |
| | | | | | | | |
| GenericValueConstraint | main branch | 5.765 ns | 0.0085 ns | 0.0075 ns | 1.00 | Baseline | - |
| GenericValueConstraint | feature branch | 5.584 ns | 0.0079 ns | 0.0070 ns | 0.97 | Faster | - |
| | | | | | | | |
| StaticValueConstraint | main branch | 3.117 ns | 0.0025 ns | 0.0022 ns | 1.00 | Baseline | - |
| StaticValueConstraint | feature branch | 3.112 ns | 0.0037 ns | 0.0033 ns | 1.00 | Same | - |
| | | | | | | | |
| StaticReferenceConstraint | main branch | 4.827 ns | 0.0043 ns | 0.0039 ns | 1.00 | Baseline | - |
| StaticReferenceConstraint | feature branch | 4.822 ns | 0.0053 ns | 0.0047 ns | 1.00 | Same | - |
| | | | | | | | |
| ConditionalPositiveDispatch | main branch | 2.533 ns | 0.0023 ns | 0.0021 ns | 1.00 | Baseline | - |
| ConditionalPositiveDispatch | feature branch | 2.911 ns | 0.0032 ns | 0.0029 ns | 1.15 | Slower | - |
| | | | | | | | |
| ConditionalNegativeCast | main branch | 2.746 ns | 0.0106 ns | 0.0099 ns | 1.00 | Baseline | - |
| ConditionalNegativeCast | feature branch | 2.543 ns | 0.0029 ns | 0.0025 ns | 0.93 | Faster | - |
| | | | | | | | |
| ReflectionIsAssignable | main branch | 1.598 ns | 0.0049 ns | 0.0046 ns | 1.00 | Baseline | - |
| ReflectionIsAssignable | feature branch | 1.592 ns | 0.0014 ns | 0.0013 ns | 1.00 | Same | - |
| | | | | | | | |
| ReflectionInterfaceMap | main branch | 125.223 ns | 0.3113 ns | 0.2912 ns | 1.00 | Baseline | 64 B |
| ReflectionInterfaceMap | feature branch | 130.751 ns | 0.3203 ns | 0.2996 ns | 1.04 | Slower | 64 B |

There are three `Slower` results in this run: the reference constrained call at about 6%, conditional-positive dispatch at about 15%, and `Type.GetInterfaceMap` at about 4%. The other ordinary paths are either `Faster` or `Same`: seven are `Faster` and 11 are `Same`.

Managed allocation is unchanged.

### Feature path vs matched nominal path

Each pair contains a nominal baseline and its extension-interface counterpart.

| Method | Mean | Error | StdDev | Ratio | MannWhitney(1%) | Allocated |
| --- | ---: | ---: | ---: | ---: | --- | ---: |
| NominalArrayStore | 7.482 ns | 0.1209 ns | 0.1131 ns | 1.00 | Baseline | - |
| ExtensionArrayStore | 8.865 ns | 0.1583 ns | 0.1481 ns | 1.19 | Slower | - |
| | | | | | | |
| NominalExplicitCast | 2.864 ns | 0.0298 ns | 0.0278 ns | 1.00 | Baseline | - |
| ExtensionExplicitCast | 3.641 ns | 0.0134 ns | 0.0125 ns | 1.27 | Slower | - |
| | | | | | | |
| NominalInterfaceOwnedPositiveCast | 2.633 ns | 0.0500 ns | 0.0468 ns | 1.00 | Baseline | - |
| ExtensionInterfaceOwnedPositiveCast | 2.622 ns | 0.0325 ns | 0.0304 ns | 1.00 | Same | - |
| | | | | | | |
| NominalTypeOwnedPositiveCast | 2.430 ns | 0.0247 ns | 0.0219 ns | 1.00 | Baseline | - |
| ExtensionTypeOwnedPositiveCast | 3.455 ns | 0.0241 ns | 0.0213 ns | 1.42 | Slower | - |
| | | | | | | |
| NominalUnrelatedNegativeCast | 2.594 ns | 0.0169 ns | 0.0158 ns | 1.00 | Baseline | - |
| ExtensionUnrelatedNegativeCast | 3.489 ns | 0.0089 ns | 0.0083 ns | 1.34 | Slower | - |
| | | | | | | |
| NominalConditionalNegativeCast | 2.608 ns | 0.0204 ns | 0.0191 ns | 1.00 | Baseline | - |
| ExtensionConditionalNegativeCast | 3.309 ns | 0.0208 ns | 0.0195 ns | 1.27 | Slower | - |
| | | | | | | |
| NominalConditionalPositiveDispatch | 2.550 ns | 0.0041 ns | 0.0038 ns | 1.00 | Baseline | - |
| ExtensionConditionalPositiveDispatch | 4.269 ns | 0.0074 ns | 0.0061 ns | 1.67 | Slower | - |
| | | | | | | |
| NominalGenericValueConstraint | 5.801 ns | 0.0109 ns | 0.0102 ns | 1.00 | Baseline | - |
| ExtensionGenericValueConstraint | 5.989 ns | 0.0058 ns | 0.0055 ns | 1.03 | Slower | - |
| | | | | | | |
| NominalReferenceConstraint | 5.037 ns | 0.0057 ns | 0.0053 ns | 1.00 | Baseline | - |
| ExtensionReferenceConstraint | 5.765 ns | 0.0148 ns | 0.0139 ns | 1.14 | Slower | - |
| | | | | | | |
| NominalStaticReferenceConstraint | 4.852 ns | 0.0113 ns | 0.0100 ns | 1.00 | Baseline | - |
| ExtensionStaticReferenceConstraint | 4.647 ns | 0.0031 ns | 0.0027 ns | 0.96 | Faster | - |
| | | | | | | |
| NominalStaticValueConstraint | 3.121 ns | 0.0038 ns | 0.0035 ns | 1.00 | Baseline | - |
| ExtensionStaticValueConstraint | 2.943 ns | 0.0045 ns | 0.0042 ns | 0.94 | Faster | - |
| | | | | | | |
| NominalValueConstraint | 2.933 ns | 0.0039 ns | 0.0035 ns | 1.00 | Baseline | - |
| ExtensionValueConstraint | 2.917 ns | 0.0038 ns | 0.0034 ns | 0.99 | Same | - |
| | | | | | | |
| NominalBaseInterfaceDispatch | 2.538 ns | 0.0047 ns | 0.0044 ns | 1.00 | Baseline | - |
| ExtensionBaseInterfaceDispatch | 4.452 ns | 0.0077 ns | 0.0072 ns | 1.75 | Slower | - |
| | | | | | | |
| NominalBoxedValueGet | 4.078 ns | 0.0067 ns | 0.0063 ns | 1.00 | Baseline | - |
| ExtensionBoxedValueGet | 3.439 ns | 0.0122 ns | 0.0114 ns | 0.84 | Faster | - |
| | | | | | | |
| NominalBoxedValueIncrement | 6.201 ns | 0.0176 ns | 0.0164 ns | 1.00 | Baseline | - |
| ExtensionBoxedValueIncrement | 5.341 ns | 0.0187 ns | 0.0175 ns | 0.86 | Faster | - |
| | | | | | | |
| NominalDelegateDispatch | 2.743 ns | 0.0035 ns | 0.0031 ns | 1.00 | Baseline | - |
| ExtensionDelegateDispatch | 2.796 ns | 0.0155 ns | 0.0145 ns | 1.02 | Same | - |
| | | | | | | |
| NominalInterfaceOwnedDispatch | 2.740 ns | 0.0028 ns | 0.0025 ns | 1.00 | Baseline | - |
| ExtensionInterfaceOwnedDispatch | 4.225 ns | 0.0248 ns | 0.0232 ns | 1.54 | Slower | - |
| | | | | | | |
| NominalTypeOwnedDispatch | 2.538 ns | 0.0028 ns | 0.0026 ns | 1.00 | Baseline | - |
| ExtensionTypeOwnedDispatch | 3.464 ns | 0.0082 ns | 0.0077 ns | 1.37 | Slower | - |
| | | | | | | |
| NominalExactDevirtualization | 2.930 ns | 0.0043 ns | 0.0040 ns | 1.00 | Baseline | - |
| ExtensionExactDevirtualization | 2.749 ns | 0.0033 ns | 0.0029 ns | 0.94 | Faster | - |
| | | | | | | |
| NominalReflectionInterfaceMap | 133.883 ns | 0.3947 ns | 0.3692 ns | 1.00 | Baseline | 64 B |
| ExtensionReflectionInterfaceMap | 316.105 ns | 0.6308 ns | 0.5901 ns | 2.36 | Slower | 64 B |
| | | | | | | |
| NominalReflectionIsAssignable | 1.599 ns | 0.0020 ns | 0.0019 ns | 1.00 | Baseline | - |
| ExtensionReflectionIsAssignable | 4.477 ns | 0.0085 ns | 0.0079 ns | 2.80 | Slower | - |

It shows the following performance characteristics in this run:

- **Adapter dispatch:** type-owned witness dispatch is about 37% slower, interface-owned dispatch about 54% slower, and inherited base-interface dispatch about 75% slower.
- **Delegate and array paths:** delegate dispatch is about 2% slower and classified `Same`, while array store plus readback is about 18% slower.
- **Casts:** interface-owned positive cast is classified `Same`, type-owned positive cast is about 42% slower, explicit cast about 27% slower, and an unrelated negative cast on a marked receiver about 34% slower.
- **Conditional resolution:** negative lookup is about 27% slower and positive adapter dispatch about 67% slower.
- **Constrained calls:** the reference constrained call is about 14% slower, while the static reference constrained call is about 4% faster.
- **No-box value paths:** the shared generic value constraint is about 3% slower and classified `Slower`; the non-generic value constraint is classified `Same`, and the static value constraint is about 6% faster.
- **Exact devirtualization:** about 6% faster and classified `Faster`.
- **Boxed adapters:** reads are about 16% faster and increment-plus-readback about 14% faster.
- **Reflection:** extension `IsAssignableFrom` is about 180% slower and extension `GetInterfaceMap` about 136% slower.

Managed allocation is still unchanged.

### First-time pair resolution

Below is the benchmark that measures resolution of a receiver-interface pair for the first time.

Each iteration creates 64 fresh receiver types before timing starts, then queries each pair once. The results are reported per pair. The declarations and module indexes are already initialized, so this does not include the cost of loading them for the first time. The `TypeOwnedManyInterfaces` case gives each receiver 128 empty nominal interfaces.

| Method | Scenario | Mean | Error | StdDev | Allocated |
| --- | --- | ---: | ---: | ---: | ---: |
| ResolveFreshPairs | TypeOwnedInherited | 411.1 ns | 9.84 ns | 27.42 ns | - |
| ResolveFreshPairs | TypeOwnedManyInterfaces | 638.9 ns | 12.59 ns | 34.67 ns | - |
| ResolveFreshPairs | InterfaceOwnedGeneric | 1,158.9 ns | 28.86 ns | 80.45 ns | - |
| ResolveFreshPairs | ConditionalRecursivePositive | 1,551.0 ns | 30.91 ns | 78.67 ns | - |
| ResolveFreshPairs | ConditionalNegative | 377.0 ns | 7.44 ns | 14.34 ns | - |
| ResolveFreshPairs | GenericValueRecursivePositive | 1,536.5 ns | 30.38 ns | 54.79 ns | - |

The extra cost here is expected: resolving a new receiver-interface pair involves finding applicable implementations, checking constraints, constructing the required generic instantiations, and caching the result.

For comparison, below are the ordinary operations on fresh pairs.

| Method | Id | Scenario | Mean | Error | StdDev | Ratio | MannWhitney(1%) | Allocated |
| --- | --- | --- | ---: | ---: | ---: | ---: | --- | ---: |
| ResolveFreshPairs | main branch | InheritedPositive | 4.566 ns | 0.1645 ns | 0.4216 ns | 1.00 | Baseline | - |
| ResolveFreshPairs | feature branch | InheritedPositive | 4.590 ns | 0.1454 ns | 0.3806 ns | 1.02 | Same | - |
| | | | | | | | | |
| ResolveFreshPairs | main branch | Negative | 4.688 ns | 0.0000 ns | 0.0000 ns | 1.00 | Baseline | - |
| ResolveFreshPairs | feature branch | Negative | 4.646 ns | 0.1003 ns | 0.2534 ns | 0.99 | Same | - |

Both ordinary comparisons are classified `Same`.

**Do** note that although the extension-aware benchmarks are slower than their nominal counterparts, all these results were benchmarked against a prototype implementation that is not optimized for performance (some paths are even unnecessarily repeating the computation which could have been implemented in a more efficient way), with tiered compilation and PGO disabled. With further optimizations and dynamic PGO, I believe the performance of extension-aware paths can be improved significantly.

Also, as shown in the benchmark result, the performance penalties here are pay-for-play. If the receiver hierarchy and requested interface do not participate in the feature, extension lookup should not be needed on the steady-state path. A participating cast can still pay the lookup cost even when no extension-provided member is called.

## Conclusion

This proposal implements extension interfaces not by mutating interface maps or wrapping objects, but by defining an immutable, coherent relation:

$$\text{(Receiver Type, Requested Interface)} \longrightarrow \text{Witness Interface Type}$$

This relation is evaluated lazily only after nominal resolution fails, caches results into existing runtime structures, and preserves object layout, normal interface maps, box layouts, and generic identities without enlarging `MethodTable`.

Contributor guide

Open the contributing guide

Research direction

Start by reading the linked C# language proposal and the CoreCLR prototype branch, then compare their semantics with the runtime requirements described here. Done would require an agreed, implementable design for witness discovery, inference, ownership, caching, and dispatch; this issue does not identify runtime files or tests for a first contribution.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.