Skip to content

Limitations & roadmap

E# is pre-alpha. The language is real and tested — every assembly the harness emits passes ILVerify — but the surface still moves. This page states the gaps normatively rather than implying completeness.

  • No type inference beyond trivial cases — types must be annotated or trivially resolved.
  • No operator overloading.
  • No stable IDE / LSP yet. A syntax-highlighting extension exists, and a language server is in progress on a branch — likely to be rewritten in E# itself (dogfooding) before it lands.
  • No flow analysis — no dead-code detection, unused-variable warnings, or definite-assignment enforcement for out parameters (the rule is documented but not yet a hard error).
  • No escape-analysis surfacing--show-alloc style diagnostics are planned, not present.
ConstructStatusWorkaround
async keywordn/a by designuncolored — await promotes the function; the return type selects the shape
explicit struct layout @offsetnot a keyword[StructLayout(LayoutKind.Explicit)] + [FieldOffset(N)]
ref returns (-> *T)unsupportedreturn a wrapper struct
await of a bare non-generic ValueTaskemits bad ILcall .AsTask() and await the Task
external generic type in a non-referenced BCL assemblymay resolve to objectreference the assembly, or use Chan<T> (special-cased)
for..in over chan<UserType> (closed generic, user element)limiteda Cecil limitation; mirrors the pre-ChanOps dispatch gap
ref choice elements in chan<T>limitedthe subclass-ctor call path doesn’t resolve
function literal that captures-and-returns inside an async state machinelimitedthe lambda return value doesn’t propagate at IL level

.es and .cs fuse into one assembly with bidirectional references. Still limited:

  • Cross-language method-impl bridgedata Foo : ICSharpInterface plus a promoted func describe(f: Foo) writes the InterfaceImplementation metadata but not yet the body forwarder.
  • Choice-from-C# — C# reads the discriminator and payload accessors but pattern-matches with a C# switch, not E# match (by design).
  • Generic constraints across the boundary — simple cases work; nested/numeric-constrained generics may surface gaps.
  • Local type inference via :=x := expr declares a local and infers its type from the initializer (annotation-free local binding), plus generic-argument inference at call sites.
  • Operator overloading for numeric types.
  • Auto by-ref parameter passing for large structs (in insertion at call sites).
  • task func parameters (currently thread shared state through channel parameters).
  • github/linguist registration of E# (so .es is not mislabeled as ECMAScript at the source level).

The sections below are proposals: designed and argued against the compiler, but not committed — shapes will move and some may drop. The substantial sections are the firm, load-bearing direction; the short list at the end is sketched in passing.

The largest item — and the one where today’s spec is already strong, so it’s worth being precise about the baseline.

Today. E# has two union kinds, and they already do a lot (see Types):

  • value choice — a tag + payload struct with factory methods, dot-case shorthand, multi-payload cases, and reified generics (Option<int> is a real closed generic struct, not erased to object). Always a struct.
  • ref choice — an abstract base with a sealed subclass per case (Outer_case), for recursive / polymorphic shapes (ASTs, trees); it carries identity and a shared base, and match dispatches via isinst.
  • match over either is exhaustiveness-checked, usable as an expression, and already supports multi-payload positional binding and the transparent single-payload case view.

What a choice cannot do today: carry its own methods (instance-method promotion is data-only), conform to an interface, hold a case that is an existing type, or be written inline as A | B. And the value-choice layout is naive — SequentialLayout carrying every case’s payload at once (a 5-case choice is as wide as all five payloads combined).

The direction closes exactly those gaps, making choice the one union primitive — tagged, discriminated, and pseudo-anonymous, that also carries members and conforms to interfaces.

Members & methods on a choice. In-body methods, plus instance-method promotion extended to choice receivers — so a free func area(s: Shape) attaches as shape.area(), the same attachment data gets today:

choice Shape : IShape {
Circle, Square
func area() -> int = match self { // in-body method satisfies IShape.area()
.Circle(c) { c.r * c.r * 3 }
.Square(s) { s.s * s.s }
}
}

Interface conformance (nominal). A choice : I conforms two ways: own methods (in-body or promoted), or auto-forward — when every member already conforms, the compiler synthesizes the match-and-dispatch:

data Circle : IShape { r: int }
data Square : IShape { s: int }
choice Shape : IShape { Circle, Square } // verified: each member : IShape → IShape forwarded, no bodies

A value choice boxes at the interface boundary — so conformance is “potentially not by direct dispatch”; reach for ref choice when it’s hot. Conformance stays nominal and exact (consistent with the shipped data/ref data rule).

Type-member cases — a case that is an existing type (your data/ref data, or an external CLR type), mixable with inline cases:

choice Event { Click(MouseEvent), key(code: int), closed } // type-member + inline + payload-less

Inline A | B in any type position, desugaring to an anonymous choice — the pseudo-anonymous form:

func format(v: int | string | bool) -> string = match v {
.int(n) { "n={n}" } .string(s) { s } .bool(b) { b ? "on" : "off" }
}

The headline — composable error sets. Two functions with different error types compose under ? with no wrapper choice and no .MapErr, because ? widens each error into the declared set (an explicit into / derive convert covers genuine cross-type conversions):

func load(id: Guid) -> Result<User, DbError | NetError> {
let row = dbFind(id)? // DbError widens into the set
let prof = netFetch(url)? // NetError widens in too
return ok(build(row, prof))
}

It earns the name with a real layout — overlapped (not summed) payloads, niche-filling when a payload is a nullable ref or *T, tagless for ref-only unions — common fields shared across value-choice cases (a ref choice already gets this today through its base class), and a doubling as a generic type-set bound (func max<T: int | long | double>(…)).

Pulled along by the union work, and already half-landed (transparent single-payload case views ship today). The rest is field-level binding, so an arm reads in domain terms instead of accessor noise:

match o {
.limit { side, qty, price } { qty > 0 && price > 0.0 } // not .limit(l), then l.qty …
.market { side, qty } { qty > 0 }
}

Plus positional deconstruct (.point(x, y)), guards (.Circle(c) if c.r > 100), and or-patterns (.Circle | .Square). Field patterns are pure sugar — identical IL to the whole-value-plus-.field arm.

The uncolored half already ships (see Concurrency): there is no async keyword, await alone makes a function async, the declared return type picks the machinery (ValueTask<T> default · Task<T> / Task · async-void · IAsyncEnumerable<T> streams), and async let runs bindings concurrently and awaits at first use. So async is uncolored save for the return type today.

The proposal completes it at the synchronous boundary — the corner async/await omits. Today a value/void-returning async function is awaited from an async caller; the proposal makes it callable from a sync caller with no ceremony, as a force-on-use future: the call starts eagerly and overlaps the caller’s following work, and blocks only at the first use of its result. A function returning an explicit async type (Task<T>, ValueTask<T>, IAsyncEnumerable<T>) still must be awaited or handled — no silent sync-over-async.

func work() -> int { // a sync function — no await of its own
let a = fetch() // uncolored async call — starts now, work() runs on
doStuff() // fetch() overlaps this
return a + 1 // first use → join: read if ready, block only on the remainder
}
callerspellingstartjoin at first usethread
asyncasync let a = f() (today)eagerawait (suspend)released
synclet a = f() (proposed)eagerblock (remainder only)held

The return type stays the only knob: value/void is the caller’s choice (auto-bridged at the sync join); an async type is yours to handle. It buys overlap without coloring — paying a parked thread at the join rather than propagating color up the call chain — uncolored for CLI / app code, colored when a server is trying not to park threads. The sync-join half is proposed, not implemented; the uncolored-await and return-shape halves already ship.

Today. Construction already differs by kind, and the value side is well covered:

  • A value data is built with a composite literal Point { x: 1, y: 2 } (fields comma- or newline-separated), an optional positional form (data Vec2(x, y)Vec2(3, 4)), or a factory function. with { … } does non-destructive update, readonly data / let fields control mutability, and a data has no constructor at all — an init block on it is ES3012.
  • A ref data is built with an init(args) block (a real .ctor), with : base(args) chaining to a parent (ES2128 on mismatch) and field defaults that run before the body; methods live in the body, and a composite literal works too.

What’s missing today is on the ref data side: effectively one constructor — no overloading, no : this delegation, no required fields (an omitted field silently defaults), no Deconstruct, no sub-public constructor visibility (ctors are pub/internal only), and no default / named arguments.

The direction brings the ref data construction surface to C# parity — the 1 way today → 5–6 principled birth-shapes, contained to ref data:

  • Overloaded init — multiple blocks distinguished by signature (exact-match resolution; ambiguity is an error, no C#-style “better conversion” ranking).
  • : this(...) delegation — a secondary init chains to a sibling (the dual of today’s : base(...)).
  • Default + named argumentsconnect("localhost", useTls: false); the main “vary one input” tool, so most cases need no overload. E# materializes defaults at the call site, and also emits [Optional] / .param so a C# caller sees them.
  • required fields — a composite literal must supply them; omission becomes an error, emitted with [RequiredMember] for C# interop.
  • Deconstruct — the inverse of positional construction, so let (a, b) = point works from E# and C# (today only tuples destructure, via .ItemN).
  • Sub-public constructor visibilityprivate / protected constructors, for factory-enforced or base-only construction.

It dovetails with DI: a single-ctor ref data is unambiguous for ActivatorUtilities, so overloading is opt-in for types that genuinely have distinct birth shapes — a registered service stays single-ctor (or annotates [ActivatorUtilitiesConstructor]).

The bigger ergonomic win, separate from the parity batch: a ref data declares its construction parameters in a header, and any parameter used in a method body is captured — the compiler synthesizes the backing field, with no field declaration and no self.x = x:

ref data UserService(store: IUserStore, cache: ICache, maxRetries: int) {
func lookup(id: Guid) -> Result<User, DbError> {
let hit = cache.get(id) // captured — a field is synthesized because it is used here
if hit != nil { return ok(hit) }
return store.find(id)
}
}

Capture is on-use: a header parameter referenced only in init / field defaults stays a plain parameter and produces no field (no courier). Synthesized fields are let and non-public; the header is the primary constructor, and an explicit init becomes a secondary that delegates with : this(...). Constructor-injected dependencies — the single largest source of boilerplate — collapse from three mentions to one. (Value data keeps its positional data Vec2(x, y) DTO form, deliberately distinct.)

Firmer but smaller, in passing:

  • Dependency injection — first-class Microsoft.Extensions.DependencyInjection: services are ref data with init(deps), interface registration rides nominal conformance, no E# container.
  • Leaner concurrency stdlibJob retired; task func returns Task<T>; the stdlib keeps only what the BCL lacks (Result, select, TaskScope).
  • Newline-insignificant parameter & argument lists — a newline separates like a comma inside ( … ).
  • More auto-derivesorder / hash / json / convert via a user-extensible provider, plus a must_use warning on a discarded Result.
  • C#-facing extension methods — a non-escaping pointer-receiver function emitted with [Extension], so a C# consumer calls v.Bump() instead of Host.bump(ref v).
  • open as an .esproj opt-in — E#‘s OO stance is that a class is either abstract or sealed; open ref data (both instantiable and inheritable) is the deliberate exception, so it becomes an explicit project setting, disabled by default — explicitness over habit.

(Earlier and rougher — impl Trait opaque returns, typestate-style markers — are parked.)

The corpus is the contract, and it grows fast — the working target is ~500–1,000 new tests per week, more when the work shifts to focused, dogfooded programs. E# is increasingly written in E#: the corpus extractor that produced the published corpus is an E# program, and the language server is being rewritten in it. The corpus and this specification track that growth.