Skip to content

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.

KindKeywordSemanticsCLR form
Value typestructcopy-on-assign, structural equality, no identitystruct
Reference typeclassidentity, reference equality, inheritanceclass (sealed by default)
Value unionuniontagged union, value semanticstag enum + struct
Reference unionref uniontagged union, reference semanticsabstract base + sealed subclass per case
Enumerationenumnamed integral constantsSystem.Enum (int32)
Interfaceinterfacenominal contractCLR interface
Delegatedelegate funcnominal function typesealed MulticastDelegate
Pointer*Tmanaged reference to a T locationT& (by-ref) / __Ptr_T wrapper
Primitiveint, decimal, string, …E# primitive valuesSystem.Int32, System.Decimal, System.String, …
Tuple(A, B)positional aggregate, value semanticsSystem.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.

A struct declares a value-semantic type. Its contract, regardless of CLR form:

  • Copy on assignment and on pass. let b = a (and passing a to a parameter, returning it, storing it in a field or collection) copies the value. Mutating b never affects a.
  • No object identity. There is no reference equality and no nil for a plain struct value — a T always 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 matching GetHashCode. Without it, equality is the CLR default.
  • No init block. Construction is by composite literal T { field: v }, the positional form struct 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.

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.

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.

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.

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.

*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.

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.

Kind== / equalityIdentity
struct (with @derive(equality) or @derive(record))structural — field-by-fieldnone
struct (without)CLR default for the chosen formnone
classreference equality (default)yes — distinct instances are distinct
enumunderlying-value equalityn/a
primitive / tuplevalue equalitynone

@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).

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 TT?. 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.