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.
Not implemented
Section titled “Not implemented”- 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
outparameters (the rule is documented but not yet a hard error). - No escape-analysis surfacing —
--show-allocstyle diagnostics are planned, not present.
Unsupported constructs and workarounds
Section titled “Unsupported constructs and workarounds”| Construct | Status | Workaround |
|---|---|---|
async keyword | n/a by design | uncolored — await promotes the function; the return type selects the shape |
explicit struct layout @offset | not a keyword | [StructLayout(LayoutKind.Explicit)] + [FieldOffset(N)] |
ref returns (-> *T) | unsupported | return a wrapper struct |
await of a bare non-generic ValueTask | emits bad IL | call .AsTask() and await the Task |
| external generic type in a non-referenced BCL assembly | may resolve to object | reference the assembly, or use Chan<T> (special-cased) |
for..in over chan<UserType> (closed generic, user element) | limited | a Cecil limitation; mirrors the pre-ChanOps dispatch gap |
ref choice elements in chan<T> | limited | the subclass-ctor call path doesn’t resolve |
| function literal that captures-and-returns inside an async state machine | limited | the lambda return value doesn’t propagate at IL level |
Mixed .es + .cs — known gaps
Section titled “Mixed .es + .cs — known gaps”.es and .cs fuse into one assembly with bidirectional references. Still limited:
- Cross-language method-impl bridge —
data Foo : ICSharpInterfaceplus a promotedfunc describe(f: Foo)writes theInterfaceImplementationmetadata 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.
Planned (near-term)
Section titled “Planned (near-term)”- Local type inference via
:=—x := exprdeclares 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 (
ininsertion at call sites). task funcparameters (currently thread shared state through channel parameters).github/linguistregistration of E# (so.esis not mislabeled as ECMAScript at the source level).
Roadmap — design in flight
Section titled “Roadmap — design in flight”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.
Union types — choice grows up
Section titled “Union types — choice grows up”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 toobject). 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, andmatchdispatches viaisinst.matchover 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 bodiesA 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-lessInline 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>(…)).
Pattern matching
Section titled “Pattern matching”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.
Colorless async
Section titled “Colorless async”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}| caller | spelling | start | join at first use | thread |
|---|---|---|---|---|
| async | async let a = f() (today) | eager | await (suspend) | released |
| sync | let a = f() (proposed) | eager | block (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.
Constructor parity — the init surface
Section titled “Constructor parity — the init surface”Today. Construction already differs by kind, and the value side is well covered:
- A value
datais built with a composite literalPoint { 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/letfields control mutability, and adatahas no constructor at all — aninitblock on it is ES3012. - A
ref datais built with aninit(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 secondaryinitchains to a sibling (the dual of today’s: base(...)).- Default + named arguments —
connect("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]/.paramso a C# caller sees them. requiredfields — a composite literal must supply them; omission becomes an error, emitted with[RequiredMember]for C# interop.Deconstruct— the inverse of positional construction, solet (a, b) = pointworks from E# and C# (today only tuples destructure, via.ItemN).- Sub-public constructor visibility —
private/protectedconstructors, 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]).
Primary-constructor capture
Section titled “Primary-constructor capture”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.)
Also in flight
Section titled “Also in flight”Firmer but smaller, in passing:
- Dependency injection — first-class
Microsoft.Extensions.DependencyInjection: services areref datawithinit(deps), interface registration rides nominal conformance, no E# container. - Leaner concurrency stdlib —
Jobretired;task funcreturnsTask<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-derives —
order/hash/json/convertvia a user-extensible provider, plus amust_usewarning on a discardedResult. - C#-facing extension methods — a non-escaping pointer-receiver function emitted with
[Extension], so a C# consumer callsv.Bump()instead ofHost.bump(ref v). openas an.esprojopt-in — E#‘s OO stance is that a class is eitherabstractor 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.)
Cadence
Section titled “Cadence”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.