Skip to main content
Version: 0.3

Expressions and statements

G# expression syntax is compact, with CLR-oriented additions for nullability, async, exceptions, and interop. The exact precedence table is in the language specification.

Operators

Unary operators include numeric identity and negation, logical not, bitwise complement, address-of, dereference, channel receive, and await. Binary operators are left-associative except ??, which is right-associative and sits below || and above the ternary conditional. Multiplicative, shift, bitwise, additive, comparison, logical-and, logical-or, null-coalescing, and range levels are implemented. The type-test operator expr is T returns bool and the safe-cast operator expr as T returns T (or T? for value types) or nil on failure; both sit at the comparison precedence level. The conditional (ternary) expression cond ? whenTrue : whenFalse is a normal expression; both arms must share a common type, otherwise GS0263 fires. User operator overloads are supported through receiver operator declarations.

Calls, access, and literals

Calls use parentheses. Generic calls use bracketed type arguments. Member access uses ., null-conditional access uses ?., indexing uses brackets, and null-conditional indexing uses ?[. These postfix operators chain after any primary expression, including a parenthesized one — (a + b).GetType(), (nums)[0], and ("s").Length are all valid. The one exception is a bare numeric literal: write (42).ToString() rather than 42.ToString, which is ambiguous with float-literal lexing. Struct literals use field labels; data structs can be copied with with updates.

a?[i] evaluates the receiver a exactly once; when it is nil the whole expression yields nil and the index is not evaluated, otherwise the result is the indexed value lifted to the nullable form of the indexer's return type. Chained forms (h?.Data?[i]?.Length) short-circuit on the first nil receiver. Null-conditional forms (?., ?[...]) are not allowed on the left-hand side of an assignment (diagnostic GS0301).

From-end indexing and ranges use C#-style tokens. xs[^1] reads the last element. xs[1..^1], xs[..3], xs[2..], and xs[..] slice arrays, slices, strings, span-like values, or call a System.Range indexer. A standalone range such as let middle = 1..^1 has type System.Range and can be reused as xs[middle]; a leading from-end marker is only valid inside brackets, so write xs[^n..] rather than let r = ^n...

Arguments may be positional, named (f(timeout: 30, retries: 3)), or ref-kind-prefixed (f(ref x), f(out var n), f(in z)). Named arguments work for free functions, user methods, user constructors, extension functions, and inherited CLR methods (including delegate Invoke); indirect calls through a function-typed variable and variadic call sites do not accept names. Ref-kind modifiers must match the parameter declaration (GS0235); passing a value to an in parameter requires an explicit in at the call site (GS0242).

let p = Point{X: 3, Y: 4}
let q = p with { X = 10 }
let value = maybePoint?.X ?? 0
let first = matrix?[0]?[0] ?? -1
let kind = (p.X + p.Y).GetType()
let last = numbers[^1]
let trimmed = numbers[1..^1]

Lambda literals use the arrow form. The canonical form infers parameter and return types from the target delegate: (x) -> expr, (a, b) -> expr, or the paren-dropped single-parameter x -> expr; block bodies use (params) -> { ... }. A block body is a statement block with an optional trailing value expression: an if-without-else and other void control-flow run as statements, return works anywhere, and a trailing expression (or return) supplies the value — full parity with func literals (void when neither yields a value). Types may also be written explicitly ((x int32) -> expr), and async lambdas use async (params) -> .... A trailing lambda may follow a call as the final argument.

Members can also use -> for a single-expression body: func Square(x int32) int32 -> x * x, prop Count int32 -> items.Length, get -> field, and set -> field = value. The C# fat arrow => is not G# syntax.

Interpolation

Interpolated strings evaluate $name and braced ${expression} fragments inside normal double-quoted strings — there is no $"…" prefix. A braced hole may add an alignment and format clause, ${expr,alignment:format}, and the delimiter-aware scanner lets a hole contain nested strings, indexers, ternaries, and even newlines. Use $$ for a literal dollar sign. By default an interpolation lowers to DefaultInterpolatedStringHandler; targeting IFormattable/FormattableString defers formatting via FormattableStringFactory.Create. Keep complex interpolation expressions readable by computing intermediate let values.

let value = 255
let label = "hi"
Console.WriteLine("hex=${value:X4}")
Console.WriteLine("padded=[${label,5}]")

Declarations, assignment, and deconstruction

Use declaration statements for new bindings and assignment for existing variables. Multi-target assignment is implemented for identifier lists. Tuple and named deconstruction use let forms. The null-coalescing compound assignment a ??= b writes b into a only when a currently reads as nil — the right-hand side is short-circuited otherwise. Prefix and postfix ++ / -- are value-producing expressions on assignable numeric lvalues: ++i yields the new value, i++ yields the old value.

let (x, y) = pair
left, right = right, left
count += 1
let before = count++
let after = ++count

var greeting string? = nil
greeting ??= "hello" // greeting is now "hello"
greeting ??= "ignored" // no-op — RHS not evaluated

If and switch

if can include a simple statement before the condition. Switch statements use block-bodied cases and do not fall through. fallthrough is reserved and diagnosed if used. Switch expressions use -> arms and require semantic coverage or a default arm.

let label = switch n {
case 0: "zero"
case 1: "one"
default: "many"
}

if itself is also a value-producing expression. In expression position the form requires an exhaustive else chain and uses brace blocks whose last expression is the branch value — there is no yield. The result type is the common type of every branch tail, computed by the same rule as the ternary conditional. Multi-statement blocks run their prefix statements for side effects and then yield the trailing expression.

let label = if n > 0 { "positive" }
else if n < 0 { "negative" }
else { "zero" }

let title = if user.IsAdmin {
log("admin route")
"Admin Dashboard"
} else {
"Home"
}

Missing the terminal else in value position reports GS0276. A block with no trailing expression in value position reports GS0277. Branches with no common type report GS0263 (shared with the ternary). The existing if-statement form (if cond { … } with optional else, optional simple-statement initializer) is unchanged.

Loops

G# has infinite for, condition for, three-part for, for in, ellipsis range loops, while, and do-while. Use for in for collection iteration.

for item in items {
Console.WriteLine(item)
}

for i in 0...10 {
Console.WriteLine(i)
}

Goto and labels

A label can prefix any statement, and goto name jumps to a label in the same function. Labels can be forward-referenced; duplicates and missing labels are diagnosed. Use this mostly for translated C# or low-level control flow — structured if, switch, and loops remain clearer for hand-written code.

goto done
Console.WriteLine("skipped")
done: Console.WriteLine("done")

Return, yield, await

return may return zero, one, or multiple expressions; multiple expressions are represented as a tuple. yield appears in iterator functions returning sequence[T]. await is a prefix expression valid in async contexts. await for consumes asynchronous sequences.

Exceptions and cleanup statements

throw, try, catch, and finally use CLR exception semantics. throw e is also an expression in value position, so name ?? throw ArgumentNullException("name"), ternary arms, return operands, lambda bodies, and arguments can throw without a separate guard. using introduces a disposable resource variable. defer schedules a call for scope exit. See Errors and cleanup.

Concurrency statements

go starts a concurrent call; scope joins child work at block exit. Channel send is ch <- value; receive is the prefix expression <-ch. select waits on channel operations and optional default cases. See Concurrency and async.