Skip to content

Function declarations and calls

This page specifies the declaration and invocation surface shared by free functions, methods, static-facet members, constructors, and BCL calls. Receiver declarations are specified separately in Methods and static facets.

FuncDecl = [ "readonly" ] "func" [ Receiver ] identifier [ Generics ]
"(" [ ParamList ] ")" [ ReturnType ] ( Block | "=" Expr ) .
ParamList = Param { Sep Param } [ Sep ] .
Param = ( [ "out" ] [ "readonly" ] identifier ":" Type
| identifier ":" "*" Type ) [ "=" Expr ] .
ReturnType = "->" Type .
ReturnsClause = "returns" Type .
ArgList = Arg { Sep Arg } [ Sep ] .
Arg = [ "out" | "&" | "*" ] Expr
| identifier ":" Expr .
Sep = "," | newline .

FuncDecl without a Receiver declares a free function. At namespace scope it belongs to the namespace host; in static Foo { ... } it belongs to that static facet; in a class body it is an in-body member. The readonly prefix is meaningful only with a receiver. An implementation shall reject readonly func without one.

Each parameter has a name and a type written in E#‘s name-first order. out, readonly, and *T are the by-reference parameter family; their storage and CLR rules are in Pointers & by-ref. The final = form is an expression body. It is equivalent to a block whose sole effect is return Expr for a non-void function, or expression evaluation for a void function.

A -> T return annotation fixes the function’s source return type. An omitted annotation is void-like unless a containing class or static facet supplies a returns T clause. That clause applies only to member functions in its own body that omit ->; an explicit arrow always wins.

static Parse {
returns Result<int, string>
func port(text: string) = parsePort(text) // Result<int, string>
func count(text: string) -> int = text.Length
}

returns is not an expression, a function annotation, or a namespace declaration. It is a default for the enclosed member declarations only.

A parameter default is part of the declaration:

func connect(host: string, port: int = 443, useTls: bool = true) -> string =
"{host}:{port}:{useTls}"

The default expression shall be a constant shape: a foldable literal, nil, or a composite literal, dot-case, or Result construction whose inputs are themselves constant shapes. Any other default is ES2180. A default is materialized at the call site, not allocated once at the declaration; every omitted argument gets a fresh evaluation of its default expression.

A literal default is also represented in CLR metadata with [Optional] and a parameter constant. Thus a C# caller sees the same trailing optional parameter rather than a language-only convention. Defaults that have no CLR metadata representation still retain their E# call-site behavior.

An argument without name: is positional. A named argument chooses the parameter with that exact name:

let endpoint = connect("api.example", useTls: false, port: 8080)

At every call site — E# free function, method, constructor, : this, : base, or BCL member — all positional arguments shall precede all named arguments. The named suffix may be written in any order. Named arguments are not available for union or enum case construction.

The following errors are defined by the call shape:

DiagnosticCondition
ES2181a positional argument follows a named argument
ES2182a named argument names no parameter
ES2183required arguments are missing, or too many arguments are supplied
ES2184one parameter is supplied more than once

Arguments are evaluated in parameter order, not their written order. In the call above the port expression evaluates before the useTls expression because port precedes useTls in connect’s signature. Omitted defaults materialize in that same order. This rule makes observable effects a property of the callee’s signature rather than a cosmetic choice in a caller.

E# resolves callable overloads by arity and supplied argument names, never by a better-conversion or argument-type ranking. Two candidates that have the same applicable parameter count and names are indistinguishable; a program shall not depend on a C#-style conversion preference to choose one.

For class constructors, same-arity init declarations are rejected at the declaration itself by ES2185. Use a distinct arity, a default parameter, or one constructor that performs the shared dispatch explicitly.

External overload sets are type-aware. The arity-and-names rule governs E#-declared callables, where same-arity overloads are refused at the declaration. A referenced assembly’s overload set is not under that rule and routinely holds several same-arity candidates that differ only by parameter type (Interlocked.Exchange(ref int, int) beside (ref long, long)), so a call into one selects by argument type. Two ranks, in order:

  1. An argument whose type is already settled — a typed binding, or &place, which selects by the addressed place’s type — pins the candidate.
  2. An unbound numeric literal only breaks ties among the candidates the settled arguments allow. A literal follows the overload the real arguments chose; it never chooses one.

The order is the whole rule. Ranking them equally lets Interlocked.Exchange(&slot, 11) on a long slot select the int pair — the literal outvoting the receiver — which is metadata the verifier rejects, at a call that reads as correct.

FuncDecl may introduce generic parameters with <...>. A call may write its type arguments explicitly, or leave them to the inference rules in Generics:

func pair<A, B>(left: A, right: B) -> (A, B) = (left, right)
let inferred = pair(1, "one")
let explicit = pair<int, string>(1, "one")

Inference is reified and follows the parameter/argument constraints, including target-typed lambda arguments. It does not turn overload resolution into type-ranking: arity and names still select the candidate before its generic arguments are closed.

A C# extension method — a static method whose first parameter carries [ThisAttribute] — is callable from E# through member syntax on its receiver, receiver.name(rest), exactly as C# calls it. This is how the BCL’s extension surface is reached: LINQ (xs.Select(...), xs.Where(...)), the span helpers (span.SequenceEqual(other), span.IndexOf(v)), and any third-party this-parameter method. The receiver binds to the method’s first parameter, not to a hidden instance this; the remaining arguments follow by the ordinary arity-and-name rule, and generic type parameters are pinned by the inference algorithm with the receiver pinning first.

The receiver is an argument, so it takes the same implicit conversions an ordinary argument would at that parameter — including the implicit span conversions. A Span<T> or T[] receiver therefore flows into a this ReadOnlySpan<T> extension via op_Implicit:

let magic = stackalloc byte[](4) // Span<byte>
readHeader(magic)
if not magic.SequenceEqual(HeaderMagic) { // Span → ReadOnlySpan (op_Implicit) receiver;
throw ModelFileException("bad header magic") // HeaderMagic (byte[]) → ReadOnlySpan arg
}

The receiver is passed by value; its address is taken only when the receiver slot is genuinely by-ref (this ref / this in on the C# side).

An extension is also callable on its host, with the receiver as argument zero: Host.name(receiver, rest). This is the same method, reached the other way. An extension emits a plain static method, every .NET language may call it that way, and E# reads its own extensions out of the same metadata it hands everyone else — so both spellings resolve to one method and agree in every respect.

pub static Text {
pub func (ext s: string) shout() -> string = s + "!"
}
let loud = "hi".shout() // member form
let same = Text.shout("hi") // static form — one method, one answer
let n = Enumerable.Count(xs) // an imported C# extension through its own host

The two spellings mirror each other across the language boundary. C# writes the receiver first in the parameter list and hides it at the member call. E# writes it outside the parameter list, in a receiver block, and supplies it at the static call. Generic inference is unchanged: the receiver still pins first, it simply arrives as an argument.

E# declares extensions with an ext receiver in a static class, per-method or through an ext (s: T) { ... } block — see Methods → extensions for the declaration forms, the resolution tiers, and the CLR mapping. A plain receiver block at namespace scope still declares an attached method, not a detached extension; ext is what distinguishes them — and an attached method stays method-only (ES2142). The static form belongs to ext, which is detached by definition, not to attachment. For an extension a namespace-scope block hosts, the host is the namespace host, so its static form is the bare free call: slug("A B") and "A B".slug() reach one method.