Types
E# has a small set of type kinds. The split that matters most is value vs. identity: data
is value-semantic by default, ref data is the object world you opt into.
data — the value-semantic default
Section titled “data — the value-semantic default”data is the kind you reach for most. It carries a value-semantic contract:
- Copy-on-assign —
let b = acopies; mutatingbnever touchesa. - No object identity — two
datavalues 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.
nilis invalid on plainT— nullability requires*TorT?.- The CLR form isn’t part of the contract — the compiler represents a
dataas a struct or a class (small/simple stays a struct; large/reference-heavy/heavily-copied silently becomes a class). The contract holds either way, so you never have to care; a C# consumer sees a stable surface regardless. Pin it with[StackAlloc]/[HeapAlloc]when you have a measured reason.
data Point { x: int, y: int }
let a = Point { x: 1, y: 2 }let b = a // a copy — mutating b never touches aTo a C# eye, data reads a lot like a record — value equality, with, copy semantics — but
record is a C#-language construct, not a CLR primitive. data is E#‘s own take: it lowers to a plain
CLR struct or class, 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 data — an init block on a data 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 data can’t contain itself by value (that would be infinitely large) — break the cycle with a pointer
(ES2002). See Pointers & memory.
data Node { value: int, next: *Node } // *Node, not Nodedata Tree { value: int, children: List<*Tree> } // through a generic container tooDeclaration 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:
data Field { key: string, value: Json } // references Json, declared belowref choice Json { jnull jobj(fields: List<Field>) // ...which references Field — a mutual cycle}Field mutability
Section titled “Field mutability”Fields are mutable by default. Mark a field let (immutable after construction — emits initonly) or
var (explicitly mutable):
data Cursor { var position: int, let label: string }Positional form
Section titled “Positional form”Sugar for the full form plus positional construction — you still get the composite form too:
data Vec2(x: int, y: int) // construct as Vec2(3, 4) or Vec2 { x: 3, y: 4 }readonly data
Section titled “readonly data”Sugar for “all fields let”, and it also emits [IsReadOnly] on the struct, telling the JIT to skip
defensive copies on in parameters:
readonly data RegisterFile { rax: long, rbx: long, rip: long, flags: int }with — non-destructive update
Section titled “with — non-destructive update”Copy a value and overwrite specific fields, producing a new value — zero allocation, zero heap. with
is value-only (using it on a ref data is an error):
let p1 = Point { x: 3, y: 4 }let p2 = p1 with { x: 10 } // p1.x == 3, p2.x == 10, both y == 4Struct embedding
Section titled “Struct embedding”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:
data Transform { Vec2, var scale: double } // t.x, t.magnitude() reach into Vec2data Entity { *Vec2, name: string } // promoted through the pointer (nullable)Generic data
Section titled “Generic data”Type parameters are reified — each instantiation is a real closed type at runtime, not an erasure:
data Pair<A, B> { first: A, second: B }let p = Pair<int, string> { first: 1, second: "x" }Browse data examples → · embedding →
ref data — identity and the object world
Section titled “ref data — identity and the object world”ref data 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 can live
directly in the body, init(...) blocks are real constructors, and fields can carry defaults that run
before the init body.
ref data Server { let host: string = "localhost" // field default var port: int = 8080 init(port: int) { self.port = port } func describe() -> string = "{self.host}:{self.port}"}Inheritance is opt-in and sealed by default (open / abstract, virtual / abstract / : func,
init(...) : base(...)) — it has its own page: Inheritance.
Browse inheritance examples →
choice — tagged unions
Section titled “choice — tagged unions”A choice 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.
choice AuthError { invalidCredentials accountLocked(untilUtc: DateTimeOffset) // multi-payload cases allowed rateLimited(retryAfterMs: int)}
let err = AuthError.invalidCredentials() // factory formGeneric choice is reified — Option<int> is a real closed generic struct, not an erasure to
object; a match binds the payload at its substituted type:
choice Option<T> { some(value: T), none }let o = Option<int>.some(99) // typed Option<int>; .some(v) → v : intDot-case shorthand works when the type is known from context:
func findUser(id: Guid) -> Option<User> { if user == nil { return .none } return .some(user)}ref choice — sealed class hierarchy
Section titled “ref choice — sealed class hierarchy”The identity-carrying variant — an abstract base with a sealed subclass per case — for recursive, polymorphic structures (ASTs, UI trees, state machines).
ref choice 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)) // factorylet sum = Expr_add { left: Expr_literal { value: 3 }, right: Expr_literal { value: 4 } } // compositeA match over a ref choice 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) }}enum — a closed set of constants
Section titled “enum — a closed set of constants”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=11Construct with the factory form — the trailing () is required, so the same call shape works across
enum, choice, and ref choice:
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 "?" }}interface — nominal conformance
Section titled “interface — nominal conformance”Interfaces emit as standard CLR interfaces. Conformance is nominal (data and ref data alike): a
type implements an interface only when it names it after :. There’s no structural auto-satisfaction —
methods still attach via promotion, but the type declares which interfaces it fulfills. The check is
exact: every method matched by name, parameter types, and return type.
interface IDescribable { func describe() -> string }
data Client : IDescribable { name: string }
func describe(c: Client) -> 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 f(x: *T)), the generated __Ptr_T wrapper implements the interface and the value type does not —
the Go pointer-receiver case. See Pointers & memory.
Nullable T?
Section titled “Nullable T?”T? is optional presence — distinct from Result<T, E>, which is for fallible
operations.
T? of… | Emits as |
|---|---|
value type (int?, data?) | Nullable<T> |
reference type (string?, ref data?) | 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)}Attributes
Section titled “Attributes”[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)]ref data Config { name: string }([StackAlloc] / [HeapAlloc] are compiler directives, not CLR attributes — they pin a data’s form,
then vanish from the assembly.)
derive — generated members
Section titled “derive — generated members”derive emits real members at compile time (not runtime reflection). Place it above the type;
combine directives with a comma:
derive equality, debugdata Packet { header: uint, length: int }derive equalitygeneratesEquals(object),GetHashCode(), and==/!=.derive debuggeneratesToString()→Packet { header: 1, length: 2 }.
Primitive types
Section titled “Primitive types”| E# | CLR | Size | E# | CLR | Size | |
|---|---|---|---|---|---|---|
int | Int32 | 4 | float | Single | 4 | |
uint | UInt32 | 4 | double | Double | 8 | |
long | Int64 | 8 | bool | Boolean | 1 | |
ulong | UInt64 | 8 | char | Char | 2 | |
short | Int16 | 2 | string | String | ref | |
ushort | UInt16 | 2 | void | Void | 0 | |
byte / sbyte | Byte / SByte | 1 |
Built-in types
Section titled “Built-in types”Result<T, E>— error-as-value;ok(...)/error(...),?propagation, combinators. See Errors.Job/Job<T>— task handles fromspawn/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.
Visibility
Section titled “Visibility”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 data Order { symbol: string, qty: int } // public typedata Internal { secret: int, pub name: string } // internal type, one public fieldFor the exact grammar and normative rules behind every type kind, see the Specification.