Skip to content

Interop & delegates

Interop is the soul of E#, not an afterthought. The language exists so you can bring a different shape into a .NET project without leaving the runtime — so this is where it pays off.

E# compiles to standard assemblies, so a C# project just references the .dll and uses the types. There’s no marker, no shim, no [ESharpInterop] — a C# consumer can’t tell it wasn’t C#.

// C# consuming an E# assembly
var client = new Client { name = "Alice", age = 30 };
Console.WriteLine(client.describe());
var session = Auth.login(request, store, hasher, tokens);
if (session.IsOk) Console.WriteLine(session.Value.token);

A struct crosses as a CLR struct; a class is a sealed CLR class; an interface is a CLR interface a C# class can implement with : IFoo; an enum is a real enum.

The other direction is just as direct — name any .NET type, with or without a using:

using "System.Net.Http"
func fetch(url: string) -> string {
let client = HttpClient()
return client.GetStringAsync(url).Result
}

The compiler resolves a generous BCL surface on its own — properties, indexers, LINQ extension methods, out parameters, params, attributes, generic methods, try/catch/throw. Common namespaces (System, System.Collections.Generic, System.Text, …) are searched implicitly, so Dictionary<string, int>(), StringBuilder(), and FormatException resolve with no using.

Optional parameters use their declared defaults. Omit a trailing optional at a C# call site and the callee’s declared default is supplied — not default(T). This holds across BCL/reference methods, sibling .cs methods, instance methods, and constructors:

let sb = StringBuilder()
let s = "a,b,c".Split(',') // omitted options arg → the method's declared default

Browse interop examples →

One .esproj can hold both .es and .cs files and compile them into a single assembly, with references flowing both ways — the JVM’s in-project polyglot story, on the CLR. It’s how E# can slot into a corner of an existing C# codebase. This is an experimental capability of the compiler workspace, aimed at full JVM-level robustness and still maturing — not yet the path for a production codebase.

A worked example: a C# base type, an E# type extending it, and a C# type extending that — one class hierarchy spanning two languages in one project.

// Handler.cs — existing C# in the project
namespace Plugins;
public class Handler
{
public string Channel() => "core";
}
// LoggingRetryHandler.cs — a C# type extending the E# type, using members from BOTH sides
public class LoggingRetryHandler : RetryHandler
{
public string Describe() => $"{Channel()} x{MaxAttempts()}"; // C# base + E# middle
}
// RetryHandler.es — an E# type slotting into the middle of the C# hierarchy
namespace Plugins
pub open class RetryHandler : Handler {
pub func MaxAttempts() -> int = 3
}

new LoggingRetryHandler().Describe() returns "core x3": the C# leaf inherits Channel() from the C# base through the E# middle, and MaxAttempts() from the E# type. Point the compiler at the directory —

Terminal window
dotnet run --project src/Esharp.Cli -- compile-il <source-dir> <out.dll>

— the E# half emits IL through Mono.Cecil, Roslyn compiles the C# half against it, and the two PEs are fused into one DLL (how the stages fit together). Both halves share internal visibility after the join. Some of this leans on Roslyn behaviors it was not specifically designed to provide — the same unsanctioned platform use early Kotlin made of the JVM — which is why the layer is experimental rather than guaranteed.

using "System.Net.Http" // import a .NET namespace
using "Geometry" // import an E# namespace (types + free funcs)
using static "System.Math" // call Max(...) unqualified
using SB = "System.Text.StringBuilder" // type alias

Importing an E# namespace brings both its types and its free functions into bare scope. An alias is the clean fix for a cross-namespace name collision — name the one you mean once and use the short form.

E# has two callable tiers, picked by intent:

  • Function pointer&func, zero-allocation, single-target, ldftn + calli. Hot paths, dispatch tables.
  • Delegate — heap-allocated, multicast. Framework interop, callbacks, events.

A bare function name converts to a delegate when the target type is known — bound directly to the real method, so reflection and interop see the actual target:

func dbl(x: int) -> int = x * 2
let f: Func<int, int> = dbl // method group → delegate
let n = f(21) // 42

delegate func mints a nominal delegate type — distinct from any structurally-identical Func<>, the same way E#‘s interfaces are nominal. You can’t pass a generic Func where a BinOp is meant by accident:

delegate func BinOp(a: int, b: int) -> int
func add(a: int, b: int) -> int = a + b
func apply(f: BinOp, a: int, b: int) -> int = f(a, b)
let op: BinOp = add // method group → nominal delegate
let r = apply(op, 20, 22) // 42

The emitted type is exactly what C# emits for delegate int BinOp(int, int), so it’s usable across the assembly boundary in both directions.

An event is a controlled subscription point over a delegate — declared field-style on class or interface (they imply identity, which value struct doesn’t have):

class Counter {
var total: int
event OnChanged: Action<int>
func add(n: int) {
self.total = self.total + n
raise OnChanged(self.total) // null-safe: a no-op with no subscribers
}
}
let c = Counter { total: 0 }
c.OnChanged += func(v) { log("now {v}") }

raise is null-safe by construction (it captures then invokes, so a handler unsubscribing mid-raise can’t cause a null call). Subscribe/unsubscribe with += / -=, on E#-declared and C#-declared events alike. The CLR shape is exactly what C# emits, so a C# consumer subscribes to an E# event — and E# subscribes to a C# event — with no glue.

Browse delegate & event examples →