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 data crosses as whatever CLR form the compiler chose (struct or class); a ref data is a sealed class; an interface is a CLR interface a C# class can implement with : IFoo; an enum is a real enum. If a data’s exact form matters to a caller, pin it with [StackAlloc] / [HeapAlloc].

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 →

The headline case: .es and .cs files compile into a single assembly with references flowing both ways. Drop both kinds in a directory and point the compiler at it —

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, and ILRepack fuses both PEs into one DLL. A C# class can inherit an E# ref data and vice versa; both halves share internal visibility after the fusion. This is the JVM-style in-project polyglot the CLR was missing, and it’s why E# can slot into an existing C# codebase a file at a time.

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 ref data or interface (they imply identity, which value data doesn’t have):

ref data 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 →