Skip to content

Native Expressions

Native expressions are the sandbox‑safe middle tier of Heddle's expression surface. They sit between plain member paths (@(A.B.C)) and the full‑C# escape hatch (@( @expr )): bare expressions written inside call parentheses — operators, literals, and registered function calls — compile to System.Linq.Expressions delegates and render with zero Roslyn involvement.

heddle
@if(Items.Count > 0){{ <h3>Comments</h3> }}
@(Price * Quantity)
@(Name ?? "anonymous")
@(IsFeatured ? "★" : "")
@(::Year - PublishedOn.Year < 1 ? "new" : "")

Anything the member tier already accepts stays a member path; an operator, a literal, or a multi‑argument function call is what makes a parameter a native expression. No new sigil is introduced.

When to use which tier

You need…UseNotes
A property valuemember path @(A.B.C)null‑safe hops, no operators
Arithmetic, comparisons, string building, a whitelisted functionnative expressionthis page
Arbitrary C#, method calls, LINQ, newthe @ C# tier @( @expr )requires ExpressionMode.FullCSharp; see csharp-api.md

The native tier is on by default (ExpressionMode.Native). Because every native expression that newly compiles was previously a compile error, turning it on is backward‑compatible.

Operators

C# precedence, verbatim. Highest to lowest:

LevelOperatorsAssoc.Notes
primary() grouping, . member (null‑safe), [] indexer, name(args) functionleftshared member‑path chain / array & indexer access / registry‑bound call
unary! - + ~rightNot, Negate, UnaryPlus, OnesComplement
multiplicative* / %left
additive+ -left+ is string concatenation when an operand is a string
shift<< >>leftintegral left operand; int right operand
relational< <= > >=leftnull operands compare false (lifted, non‑null result)
equality== !=leftobject.Equals fallback for unrelated reference/mixed types
bitwise AND&leftints, same‑enum, and bool
bitwise XOR^left
bitwise OR|left
logical AND&&leftnon‑nullable bool only, short‑circuit
logical OR||leftnon‑nullable bool only, short‑circuit
null‑coalescing??rightleft operand must be a reference type or Nullable<T>
conditional?:rightcondition must be bool; arms need a common type

Numeric promotion

Binary numeric operands are promoted to a common type before the operation, following the C# rules (first match wins): decimaldoublefloatulonglong → (uint with a signed operand becomes long) → uintint. Mixing decimal with float/double, or ulong with a signed integral, has no common type and is a compile error.

Lifted (nullable) operands

When either operand is Nullable<T>, arithmetic and bitwise operators produce a nullable result (null in → null out). Relational and equality operators produce a plain bool: any null operand compares false, and null == null is true — exactly as C#.

Why there is no ?.

Member hops are already null‑safe: a hop off a null reference yields default(T) of the property's type — null for reference types and Nullable<T>, the zero value for other value types. So @if(A.B > 0) with A == null evaluates 0 > 0 and renders nothing. Because . is null‑safe, a separate ?. operator would be redundant, and it is deliberately absent.

Literals

The full C# literal set except verbatim/interpolated/raw strings:

LiteralType
42, 0x2A, 0b101010, 1_000first of intuintlongulong that fits
42L, 42u, 42ullong / uint / ulong per the suffix
1.5, 1e3double
1.5f / 1.5d / 1.5mfloat / double / decimal
"text"string (standard escapes: \' \" \\ \0 \a \b \e \f \n \r \t \v \xH…H \uHHHH \UHHHHHHHH)
'c'char (same escape set)
true / falsebool
nulltyped by the consuming operator

String interpolation is intentionally excluded — use + or the format function instead.

this — the current model

this is the current scope's model, typed as the current scope type. It fills the one gap a bare member path can't: naming the model itself rather than something derived from it.

  • As a whole expression, this is the model passthrough — it compiles to the empty parameter and works on dynamic scopes too, exactly like an empty member path. Its flagship use is passing the current model into a parameterized slot: @list(Options){{ @out(this) }}.
  • As an operand or path root, this is a typed operand and follows the same rule as any path: this.Name, len(this), and this == null need a typed model (a dynamic scope reports HED1004). this.<name> is also the explicit escape for a model member a prop shadows.

this is a C# keyword, so it can never collide with a model member. It is expression‑tier, so it reports HED1014 under MemberPathsOnly.

Native expressions as named‑argument values

The value of a prop named argument is a native expression — @card(Article, style: Featured ? "wide" : "plain", tag: upper(Kind)). Every construct on this page is allowed there (paths off the caller model, :: root refs, operators, functions, this, literals). A named‑argument value is not a C# @‑tier expression and not a call chain: @card(A, x: @ expr) and @card(A, x: a():b()) are syntax errors. Compute anything the native tier can't express in the model or a registered function.

Registered functions

Native expressions can call functions the host has registered, plus a frozen set of built‑ins. Registration is the trust boundary: anything registered is callable from template text, and nothing else is. There is no path from template text to arbitrary methods by name.

The default built‑ins

All are invariant‑culture and never throw at render (string‑returning ones map null input to ""):

FunctionBehavior
upper(s) / lower(s)invariant upper/lower case
trim(s)trims Unicode whitespace both ends
len(s)UTF‑16 code‑unit length
contains(s, v) / startswith(s, v) / endswith(s, v)ordinal, bool
replace(s, old, new)ordinal replace; empty old returns s unchanged
substr(s, start[, length])start/length clamped into range
format(value, fmt)IFormattable.ToString(fmt, InvariantCulture)
format(fmt, args…)composite string.Format(InvariantCulture, …)
str(value)invariant Convert.ToString
abs, min, max, round, floor, ceilover int/long/double/decimal as applicable; clamped, non‑throwing
range(start, last[, step])builds a Heddle.Models.Range for @for — iterates start … last‑1 by step (default 1)

range. The two overloads (range(int, int) and range(int, int, int)) return a Heddle.Models.Range { Start, Last, Step } — an immutable value type — so @for(range(2, 10, 2)) iterates a start/step range with no embedded C#. last is exclusive; start >= last renders empty (like @for(0)). The step must be positive — unlike every other built‑in, range validates it: a zero or negative literal step is a compile error (HED4001, positioned at the step argument), and a non‑positive step known only at render throws TemplateProcessingException with the same message (a zero/negative step would never terminate the loop). A host that registers its own range governs its own step rules — the static check applies only to the built‑in. Calling @(range(1, 5)) standalone is legal and renders the readable call form range(1, 5). Note that a standalone @fn(...) accepts only a single positional expression, so a multi‑argument call must be wrapped as @( fn(a, b) )@range(1, 5) written directly is a parse error (HED0003). range is meant for @for(...).

Registering your own

csharp
var functions = new FunctionRegistry();                 // starts with the built-ins
functions.Register("titlecase", (Func<string, string>)ToTitleCase);
functions.Register("slug", typeof(MyFns).GetMethod(nameof(MyFns.Slug))); // static method

var options = new TemplateOptions { Functions = functions };
  • Names are ordinal and case‑sensitive.
  • Registering the same name with identical parameter types replaces; otherwise it adds an overload. Overload resolution ranks exact match over widening over boxing to object.
  • The registry freezes on first compile use; registering afterwards throws InvalidOperationException. Frozen registries are immutable and safe for concurrent compiles and renders.
  • null TemplateOptions.Functions means FunctionRegistry.Default (the frozen built‑ins).

Standalone vs. in‑expression calls

A standalone @fn(x) resolves in the order definition → extension → registered function. If a registered function name collides with an extension, the extension wins and a warning is emitted — invoke the function inside an expression (@( fn(x) )) to disambiguate. Inside an expression, fn(...) resolves against the registry only; a name that is an extension there is a compile error.

ExpressionMode

TemplateOptions.ExpressionMode selects the tier:

ValueMeaning
MemberPathsOnlystrict pre‑1.0 surface: member paths, nested chains, empty parameters only
Native (default)adds the native expression tier
FullCSharpimplies Native; additionally enables the inner‑@ Roslyn C# tier

AllowCSharp is a bridge over this enum: AllowCSharp = true selects FullCSharp; reading it returns whether the mode is FullCSharp. It is retained for compatibility and marked [Obsolete] since 2.x — reads and writes keep working; new code uses ExpressionMode.

Deviations from C#

Native expressions match C# except for a small, deliberate set of ergonomic choices:

  1. ==/!= on unrelated reference/mixed types compiles to a total, null‑safe object.Equals instead of a compile error.
  2. . hops (and indexer targets) are null‑safe, yielding default(T).
  3. -2147483648 types as long (first‑fit literal typing, without C#'s lexer special case); the value is identical.
  4. Enum arithmetic (enum + int) is not supported.
  5. The enum & 0‑literal special case is not carried over.
  6. User‑defined operators are honored (e.g. DateTime/TimeSpan), but user‑defined implicit conversions are not consulted during promotion or arm unification.
  7. &&/|| reject bool? with a targeted error instead of C#'s wording.

The sandbox

The compiler can only ever emit invocations of: property/indexer getters that pass the member‑tier visibility and [Hidden] filter; the MethodInfos/delegates the host registered (built‑ins included); compiler‑chosen intrinsics (string.Concat, static object.Equals, conversions); and user‑defined operator methods declared by the operand types themselves. Method‑call syntax (x.Foo()), unregistered names, dynamic‑scope operands, assignment, lambdas, new, casts, and is/as are all rejected at compile time with a positioned error — never executed. See the built‑in extension parameter docs for how @if/@for consume these expressions, and csharp-api.md for when to escalate to the @ C# tier.

For host‑side guidance on exposing models safely — DTOs, [Hidden], the registry freeze, render budgets, and encoding contexts — see Exposing models to untrusted templates.