Skip to content

Types

E# has a small set of type kinds. The split that matters most is value vs. identity: struct is value-semantic by default, class is the object world you opt into.

struct is the kind you reach for most. It carries a value-semantic contract:

  • Copy-on-assignlet b = a copies; mutating b never touches a.
  • No object identity — two struct values with equal fields are equal; there’s no reference identity.
  • Value-shaped equality — field-wise by default.
  • No shared mutation through aliases — every binding is its own value.
  • nil is invalid on plain T — nullability requires *T or T?.
struct Point { x: int, y: int }
let a = Point { x: 1, y: 2 }
let b = a // a copy — mutating b never touches a

To a C# eye, struct reads a lot like a record — value equality, with, copy semantics — but record is a C#-language construct, not a CLR primitive. struct is E#‘s own take: it lowers to a plain CLR struct, with the value-equality and with machinery generated directly.

Fields can be written on one line (comma-separated) or one per line. Construction is the composite literal T { field: value }; there are no constructors on struct — an init block on a struct is an error (ES3012). If construction needs logic, write a factory function:

func makePoint(x: int, y: int) -> Point = Point { x: x, y: y }

A struct can’t contain itself by value (that would be infinitely large) — break the cycle with a pointer (ES2002). See Pointers & memory.

struct Node { value: int, next: *Node } // *Node, not Node
struct Tree { value: int, children: List<*Tree> } // through a generic container too

Declaration order is free. A type may reference another declared later in the file, and two types may reference each other — the compiler resolves all type names regardless of order:

struct Field { key: string, value: Json } // references Json, declared below
ref union Json {
jnull
jobj(fields: List<Field>) // ...which references Field — a mutual cycle
}

Fields are mutable by default and use the bare declaration form. let and var are properties at member scope, so they provide the read/construction and read/write property forms rather than alternate field spellings:

class Cursor { position: int, let label: string, var offset: int }

Sugar for the full form plus positional construction — you still get the composite form too:

struct Vec2(x: int, y: int) // construct as Vec2(3, 4) or Vec2 { x: 3, y: 4 }

Makes every bare field readonly, and it also emits [IsReadOnly] on the struct, telling the JIT to skip defensive copies on in parameters:

readonly struct RegisterFile { rax: long, rbx: long, rip: long, flags: int }

Copy a value and overwrite specific fields, producing a new value — zero allocation, zero heap. with is value-only (using it on a class is an error):

let p1 = Point { x: 3, y: 4 }
let p2 = p1 with { x: 10 } // p1.x == 3, p2.x == 10, both y == 4

A bare type name as a field embeds it; the embedded type’s fields and methods are promoted — reachable directly on the outer type (t.x desugars to t.Vec2.x; the outer type’s own members shadow promoted names). Pointer embedding (*T) promotes through auto-deref:

struct Transform { Vec2, scale: double } // t.x, t.magnitude() reach into Vec2
struct Entity { *Vec2, name: string } // promoted through the pointer (nullable)

Type parameters are reified — each instantiation is a real closed type at runtime, not an erasure:

struct Pair<A, B> { first: A, second: B }
let p = Pair<int, string> { first: 1, second: "x" }

Positional form, Deconstruct, and required fields

Section titled “Positional form, Deconstruct, and required fields”

struct Vec2(x: int, y: int) is the positional shape — it declares the fields and a positional constructor (Vec2(3, 4)) and a synthesized Deconstruct, so a value destructures by position:

struct Vec2(x: int, y: int)
let v = Vec2(3, 4)
let (x, y) = v // x == 3, y == 4 — via the synthesized Deconstruct

A field may be marked required — then a composite literal must set it; omitting one is a compile error (other fields keep their silent zero-default). It emits [RequiredMember], so a C# object initializer enforces the same coverage:

struct Span {
required lo: int
required hi: int
label: string // still optional
}
let s = Span { lo: 2, hi: 9 } // ok
// let bad = Span { lo: 2 } // error: required field 'hi' not set

Browse struct examples → · embedding

class is a CLR class: heap-allocated, reference equality, GC-tracked. Reach for it when you genuinely want identity, shared mutable state, framework interop, or constructors. Methods live directly in the body, init(...) blocks are real constructors, and members can carry defaults that run before the init body.

class Server {
let host: string = "localhost" // read property
var port: int = 8080
init(port: int) { self.port = port }
func describe() -> string = "{self.host}:{self.port}"
}

Fields, properties, and location-aware properties

Section titled “Fields, properties, and location-aware properties”
FormMeaning
name: Tmutable field
let name: Tstored read property with implicit readonly loca
var name: Tstored read/write property with implicit writable loca
let name: T => expressioncomputed read property; no implicit location
var name: T { get => read() set(v) => write(v) }behavioral read/write property; no hidden storage
self.name: T = value in initconstructor-owned field

Use loca when a property deliberately exposes stable identity. Use scoped mut when it lends a working location and must run policy afterward:

namespace Example
class Meter {
let label: string = "requests"
var limit: int = 100
let full: bool => self.value >= self.limit
init(value: int) {
priv self.storage: int = value
}
var value: int {
loca => &self.storage
}
let guarded: int {
mut {
var working: int = self.storage
yield &working
if working < 0 { self.storage = 0 } else { self.storage = working }
}
}
}
func addOne(value: *int) { value += 1 }
func run() -> int {
let meter = Meter(40)
addOne(&meter.value)
addOne(&meter.guarded)
return meter.value // 42
}

The constructor declares private storage without putting a private field at the top of the class. value exposes that storage durably; guarded lends it only for one borrowing call and resumes even if that call throws. The exact capability and escape rules are in Properties and object initialization.

An interface can require that durable identity with var value: int { get set loca }; callers may then borrow &counter.value through the interface itself, while the implementation keeps its storage private. Interface properties must be implemented by let/var properties. A bare name: T field remains a field; declaring conformance never manufactures property accessors for it.

Constructors compose. A class may declare several init blocks (distinguished by arity and argument names, never types), a secondary may delegate to a sibling with : this(...), and parameters take defaults so one signature covers many call shapes; visibility is priv init / protected init:

class Conn {
var host: string
var port: int
init(host: string, port: int = 80) { self.host = host self.port = port }
init(host: string) : this(host, 443) { } // delegates to the 2-arg init
}
let c = Conn(host: "db", port: 5432) // named arguments

Primary-constructor capture. A positional header on a class is its primary constructor; a header parameter used in a method is captured into a synthesized private field — no field declaration, no self.x = x. A param-less init { } is the primary’s epilogue:

class UserService(store: IUserStore, cache: ICache, maxRetries: int = 3) {
var tokens: int = maxRetries // a member default may read a header param
init { if maxRetries <= 0 { tokens = 1 } } // epilogue runs after capture + field defaults
func lookup(id: Guid) -> User = cache.get(id) ?? store.find(id) // cache/store captured
}
let svc = UserService(store: sqlStore, cache: memCache)

Inheritance is opt-in and sealed by default (open / abstract, virtual / abstract / : func, init(...) : base(...)) — it has its own page: Inheritance. Browse inheritance examples →

A union is a sum type: a value that is exactly one of several named variants, each carrying its own payload. Emits as a tag enum + struct with factory methods (always a struct). It pairs with match.

union AuthError {
invalidCredentials
accountLocked(untilUtc: DateTimeOffset) // multi-payload cases allowed
rateLimited(retryAfterMs: int)
}
let err = AuthError.invalidCredentials() // factory form

Generic union is reifiedOption<int> is a real closed generic struct, not an erasure to object; a match binds the payload at its substituted type:

union Option<T> { some(value: T), none }
let o = Option<int>.some(99) // typed Option<int>; .some(v) → v : int

Dot-case shorthand works when the type is known from context:

func findUser(id: Guid) -> Option<User> {
if user == nil { return .none }
return .some(user)
}

Browse union examples →

The identity-carrying variant — an abstract base with a sealed subclass per case — for recursive, polymorphic structures (ASTs, UI trees, state machines).

ref union Expr {
literal(value: int)
add(left: Expr, right: Expr)
neg(inner: Expr)
}

Two construction forms emit equivalent IL — the factory (mirrors the dot-shorthand) and the per-case subtype composite literal (the underlying CLR type per case is Outer_case):

let tree = Expr.add(Expr.literal(3), Expr.literal(4)) // factory
let sum = Expr_add { left: Expr_literal { value: 3 }, right: Expr_literal { value: 4 } } // composite

A match over a ref union uses an isinst type pattern:

match expr {
.literal(v) { return v }
.add(l, r) { return eval(l) + eval(r) }
.neg(inner) { return 0 - eval(inner) }
}

A plain set of named constants with no payloads. Emits as a real CLR System.Enum (int32 underlying), interchangeable with C# enums. Variants without an explicit value start at 0 and increment; after an explicit value, auto-numbering resumes from value + 1:

enum Direction { north, south, east, west }
enum Level { a, b = 10, c } // a=0, b=10, c=11

Construct with the factory form — the trailing () is required, so the same call shape works across enum, union, and ref union:

let d = Direction.north()

A match on an enum needs a type hint, because variants flow through the dot-case shorthand:

match (d: Direction) {
.north { return "N" }
.south { return "S" }
default { return "?" }
}

Browse enum examples →

Interfaces emit as standard CLR interfaces. Conformance is nominal (struct and class alike): a type implements an interface only when it names it after :. There’s no structural auto-satisfaction — methods attach via receiver blocks, but the type declares which interfaces it fulfills. The check is exact: every method matched by name, parameter types, and return type. Write E# interface names with the familiar .NET I prefix (IDescribable, IMap<K, V>). It is a convention, so existing CLR interfaces and deliberately non-prefixed declarations still work.

interface IDescribable { func describe() -> string }
struct Client : IDescribable { name: string }
func (c: Client) describe() -> string = "client: {c.name}"

Why nominal: the CLR is nominal underneath, declaring the interface makes the (value-type) boxing site explicit, and it lines up with how dependency registration is written. A type that would satisfy an interface it doesn’t declare gets a warning (ES2153) naming the : IFoo to add — a structural coincidence is never silently treated as conformance.

When only the pointer method set satisfies a declared interface (a pointer-receiver method, func (x: *T) f()), the generated __Ptr_T wrapper implements the interface and the value type does not — the Go pointer-receiver case. See Pointers & memory.

T? is optional presence — distinct from Result<T, E>, which is for fallible operations.

T? of…Emits as
value type (int?, struct?)Nullable<T>
reference type (string?, class?)unwrapped (already nullable) — holds as a generic arg too: Func<string, string?> is Func<string, string>

nil fills either (initobj Nullable<T> for value types, ldnull for reference types).

func find(id: int) -> User? {
if id == 0 { return nil }
return lookupUser(id)
}

[Name] and [Name(args)] pass straight through as CLR custom attributes — same syntax as C#, with constructor arguments resolved at bind time:

[Obsolete("use v2")]
[StructLayout(LayoutKind.Explicit)]
class Config { name: string }

derive emits real members at compile time (not runtime reflection). Place it above the type; combine directives with a comma:

derive equality, debug
struct Packet { header: uint, length: int }
  • derive equality generates Equals(object), GetHashCode(), and == / !=.
  • derive debug generates ToString()Packet { header: 1, length: 2 }.
E#CLRSizeE#CLRSize
intInt324floatSingle4
uintUInt324doubleDouble8
longInt648boolBoolean1
ulongUInt648charChar2
shortInt162stringStringref
ushortUInt162voidVoid0
byte / sbyteByte / SByte1
  • Result<T, E> — error-as-value; ok(...) / error(...), ? propagation, combinators. See Errors.
  • Spawned / Spawned<T> — concurrent-work handles from spawn / task func.
  • Chan<T> — typed channels.
  • TaskScope — structured concurrency.

The last three are covered in Async & concurrency. Type resolution order: primitives → built-in generics (Result, Chan) → current unit → other files → external .NET types.

Everything is internal by default; pub makes a declaration visible outside its assembly. Two levels only — there’s no private / protected. The module is the privacy boundary. Fields inherit the enclosing type’s visibility unless overridden with pub.

pub struct Order { symbol: string, qty: int } // public type
struct Internal { secret: int, pub name: string } // internal type, one public field

For the exact grammar and normative rules behind every type kind, see the Specification.