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# types from C#
Section titled “E# types from C#”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# assemblyvar 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.
C# types from E#
Section titled “C# types from E#”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 defaultOne assembly, two languages
Section titled “One assembly, two languages”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 projectnamespace Plugins;
public class Handler{ public string Channel() => "core";}
// LoggingRetryHandler.cs — a C# type extending the E# type, using members from BOTH sidespublic 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# hierarchynamespace 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 —
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, static using, and aliases
Section titled “using, static using, and aliases”using "System.Net.Http" // import a .NET namespaceusing "Geometry" // import an E# namespace (types + free funcs)using static "System.Math" // call Max(...) unqualifiedusing SB = "System.Text.StringBuilder" // type aliasImporting 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.
Delegates
Section titled “Delegates”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 → delegatelet n = f(21) // 42Nominal delegates — delegate func
Section titled “Nominal delegates — delegate func”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 + bfunc apply(f: BinOp, a: int, b: int) -> int = f(a, b)
let op: BinOp = add // method group → nominal delegatelet r = apply(op, 20, 22) // 42The emitted type is exactly what C# emits for delegate int BinOp(int, int), so it’s usable
across the assembly boundary in both directions.
Events
Section titled “Events”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.