Type system
E# has a nominal, statically-checked type system that maps directly onto the CLR’s. Every type is one of a small set of kinds; the kind fixes the type’s semantics (value vs. reference, identity vs. structural equality) independently of the concrete CLR form the compiler chooses. This page specifies those semantics; Declarations gives the syntax and CLR mapping the exact lowering.
In this specification
Section titled “In this specification”- Kinds and nominal conformance specifies the type kinds, value/reference discipline, union forms, and interface conformance.
- Nullability, equality, and conversions specifies optional types, equality/identity, casting, boxing, numeric, tuple, and delegate conversions.
| Kind | Keyword | Semantics | CLR form |
|---|---|---|---|
| Value type | struct | copy-on-assign, structural equality, no identity | struct |
| Reference type | class | identity, reference equality, inheritance | class (sealed by default) |
| Value union | union | tagged union, value semantics | tag enum + struct |
| Reference union | ref union | tagged union, reference semantics | abstract base + sealed subclass per case |
| Enumeration | enum | named integral constants | System.Enum (int32) |
| Interface | interface | nominal contract | CLR interface |
| Delegate | delegate func | nominal function type | sealed MulticastDelegate |
| Pointer | *T | managed reference to a T location | T& (by-ref) / __Ptr_T wrapper |
| Primitive | int, decimal, string, … | E# primitive values | System.Int32, System.Decimal, System.String, … |
| Tuple | (A, B) | positional aggregate, value semantics | System.ValueTuple<…> |
Primitives are spelled in lower case (int, long, double, float, bool, string, byte,
short, char) and map to the corresponding BCL types. Any other BCL or user CLR type is referenced by
its PascalCase name and resolved per Names & resolution.
Value types — struct
Section titled “Value types — struct”A struct declares a value-semantic type. Its contract, regardless of CLR form:
- Copy on assignment and on pass.
let b = a(and passingato a parameter, returning it, storing it in a field or collection) copies the value. Mutatingbnever affectsa. - No object identity. There is no reference equality and no
nilfor a plainstructvalue — aTalways holds a value. Sharing, recursion, and nullability are expressed with*T. - Structural equality when
@derive(equality)(or@derive(record)) is present: two values are equal iff their fields are equal, with a matchingGetHashCode. Without it, equality is the CLR default. - No
initblock. Construction is by composite literalT { field: v }, the positional formstruct T(a, b), or a factory function — never a constructor (ES3012). - No by-value self-containment. A field whose type contains the enclosing type by value is
ill-formed (ES2002); break the cycle with
*T.
Reference types — class
Section titled “Reference types — class”A class is the explicit “object world” kind: a CLR class with identity and reference
equality, where aliases observe each other’s mutations and nil is a valid value. It is the home of
constructors (init), inheritance, and framework interop. It is sealed by default; open makes it
inheritable and abstract makes it non-instantiable. Constructors support overloading, : self(...)
delegation, sub-public visibility (priv / protected init), and a primary-constructor capture
header (class Foo(deps…)); the member forms (func, virtual func, abstract func, : func),
: base(...) chaining, and the inheritance diagnostics are specified in
Declarations → class and the guide page Inheritance.
Unions — union / ref union
Section titled “Unions — union / ref union”A union is a tagged union: a value is exactly one case, each carrying zero or more payload fields. A
value union has value semantics (tag enum + struct); a ref union has reference semantics (an
abstract base with a sealed subclass per case, named Outer_case). Cases are constructed by factory
(T.case(args)) or dot-case shorthand (.case(args) where the target type is known) and consumed by
match, which checks exhaustiveness.
Enumerations and interfaces
Section titled “Enumerations and interfaces”An enum is a System.Enum whose underlying type is int32 by default, or the integral primitive named by
an optional : T annotation (enum Codec: byte { … }, one of byte/sbyte/short/ushort/int/uint/long/ulong);
a bare case takes the previous value + 1, the first defaulting to 0, and a case is constructed with a trailing
() (Dir.north()). An imported enum’s members are static literal fields and are read without the
trailing () (StringComparison.Ordinal, FileAccess.ReadWrite).
An enum converts to and from its underlying integral through the ordinary native conversion —
int(codec), Codec(n) — and this holds for an imported enum exactly as for a declared one. An enum has
no runtime shape distinct from its underlying integral, so the conversion is between integrals and takes
its signedness from the underlying type; where the width is recorded (a declaration here, the metadata
there) is not the conversion’s business.
An interface is a CLR interface with nominal conformance: a type implements it only by naming it after
:, and the match is exact (name, parameter types, return type). A type that would structurally satisfy
an interface it does not name raises ES2153.
Nested types
Section titled “Nested types”A class or static body may declare nested types — enum, struct, class, union,
ref union, interface, delegate func. A nested type is a real CLR nested type (Outer.Inner),
reachable by reflection and typeof(Outer.Inner), visible to the enclosing type without qualification and
to external callers by the dotted path; one without pub is reachable only from the enclosing type. The
syntax and emission are in Declarations → nested types and
CLR mapping → nested types.
Pointers and nullability
Section titled “Pointers and nullability”*T is a managed reference to a T location — how value-semantic struct reaches shared, recursive, and
nullable flows. *Class is ill-formed (ES2003): a class is already a
reference. T? denotes an optional: for a value type it lowers to System.Nullable<T>, for a reference
type it is the underlying type with a nullability annotation. nil is the absent value. Pointers,
by-ref parameter passing (*T, readonly *T, out), address-of (&), and function pointers are
specified in Pointers & by-ref.
Generics
Section titled “Generics”Generic struct, class, union, interface, and functions are reified — each instantiation is
a real closed type at runtime (Option<int> is a distinct closed generic struct, never erased to
object). Types are keyed by name and arity, so a generic Foo<A, B> and a non-generic
static Foo coexist, as do Pair<A> and Pair<A, B>. Type-argument inference flows through lambda
arguments. The full treatment — reification, the open/closed distinction, the inference algorithm,
constraints, and default(T) — is in Generics.
Equality and identity
Section titled “Equality and identity”| Kind | == / equality | Identity |
|---|---|---|
struct (with @derive(equality) or @derive(record)) | structural — field-by-field | none |
struct (without) | CLR default for the chosen form | none |
class | reference equality (default) | yes — distinct instances are distinct |
enum | underlying-value equality | n/a |
| primitive / tuple | value equality | none |
@derive(equality) generates Equals / GetHashCode / == / !=; @derive(debug) generates
ToString(); @derive(record) generates both plus the rest of the CLR record protocol
(Declarations → the record protocol) — which on a class adds
the EqualityContract check, so two record kinds with identical fields are not equal. A class or
struct may instead declare operators from the fixed overloadable set in its companion static facet; an
explicit equality pair conflicts with either derive (ES2280).
Conversions
Section titled “Conversions”E# converts conservatively. A value of one type is usable where another is expected only through the conversions below; there are no implicit narrowing conversions and no user-defined conversion operators. Everything follows the CLR’s verifiable rules.
Identity and reference. A value is usable where its own type is expected. Up-conversion to a base
class or a named interface is implicit (conformance is nominal — Declarations →
interface); down-conversion is never implicit, and is written one of four
ways, all built on the same flow-typing engine: is T tests and smart-casts in place; as T
yields T? (the safe cast); a match type pattern (name: T) dispatches; as! T asserts at a
boundary and throws on a miss. Narrowing is sound by stability — a let/parameter/let-field path, never
a var — and honest at full generic fidelity, since generics are reified. The operators and the
smart-cast rules are in Expressions → Type narrowing;
the multi-way match form and closed-hierarchy exhaustiveness in
Pattern matching.
Numeric. None are implicit — not even widening. An int is not implicitly a long or a double;
mixing nonliteral primitive numeric types requires an explicit native conversion (double(n)). Integer arithmetic
uses the CLR’s default unchecked semantics. E# does not currently define checked or unchecked
keywords. Floating-point behavior and the Release transformation boundary are specified in
Numerics and performance.
Boxing happens only where the CLR requires a reference, never silently between value types: a value
interpolated into a string (boxed into the underlying string.Concat / string.Format), a value struct
flowing through an interface or object (except where the *T method-set wrapper carries it by
reference instead — Pointers), and explicit
object-taking BCL APIs. Unboxing is at a match test or an explicit cast.
Nullable T ↔ T?. A T is implicitly a T? (the present case); a T? is not implicitly a
T — extract with a match, a ?? fallback, or ?.. nil is the absent value of any T?, reference
type, or *T. A value-type T? lowers to System.Nullable<T>; a reference-type T? is the underlying
type with a nullability annotation.
Tuples convert element-wise to a same-arity tuple of compatible element types; destructuring
(let (a, b) = p) projects .Item1 / .Item2. An element may be labeled — (name: T, other: U) — and
read by that label (t.name), which resolves to the underlying .ItemN. Labels are metadata (a labeled
tuple is the same System.ValueTuple<…> as its unlabeled twin, so they are ignored for type equality and
conversion), and may be mixed with bare elements ((count: int, int)); the bare ones keep only .ItemN.
A member access naming neither a label nor an in-range .ItemN is
ES2285.
A tuple expression labels its elements with the same name: prefix — return (q: a / b, r: a % b) —
so a labeled return type can be constructed by name rather than by position. Construction labels are
metadata on the same footing as type labels: they name the elements of the constructed tuple when nothing
else does (let p = (x: 10, y: 2) has type (x: int, y: int)), and they are discarded in favour of the
target’s when the literal converts to an already-labeled type — reported as
ES4002, since .mine would otherwise fail to resolve with no visible cause.
A positional literal still satisfies a labeled target; the target names the elements either way. Each
element is target-typed against the corresponding element of the target, so (tag: "none", weight: nil)
against (tag: string, weight: int?) builds the int? absent value rather than inferring object.
Because E# has no one-element tuple, a label on a parenthesized expression with no comma
((x: 10)) has no element to name and is ES1013.
Delegates. A bare function name (method-group conversion) and a lambda each convert to a delegate
when the target delegate type is known — a typed parameter, a typed let, a return, or an event. An
un-annotated let f = dbl is ambiguous (delegate or function pointer?) and rejected. A delegate func
type and a structurally-identical Func<> are not interconvertible — delegate types are nominal
(Delegates & events).
Spans. The CLR’s own implicit span conversions are implicit in E# too: a T[] converts to
Span<T> and to ReadOnlySpan<T>, and a Span<T> converts to ReadOnlySpan<T>. Spans are
invariant in T, so the element must match exactly (byte[] → ReadOnlySpan<byte>, never
→ ReadOnlySpan<object>). Each is realized as the framework’s op_Implicit — a call, not a cast or a
copy — and is inserted at argument, assignment, and return positions, so a stackalloc span or a heap
array flows into any ReadOnlySpan<T>-taking BCL API without ceremony
(Numerics → span interoperability). These are the
BCL’s implicit operators, not user-defined conversions, so they fit the “follows the CLR’s verifiable
rules” stance the rest of this section takes.
Pointer forms. The two representations of *T (a managed pointer vs. the __Ptr_T heap cell)
convert to one another automatically where they meet (Pointers).
A value struct and its chosen CLR form are the same type to a C# caller; there is no separate interop
shim. A type that wants to be viewable as another exposes a method or factory — the conversion is an
ordinary, visible call.