A programming language combining affine types, dependent types, row polymorphism, and extensible effects.
AffineScript is designed for safe, efficient systems programming with:
-
Affine Types: Rust-style ownership ensuring memory safety without GC
-
Dependent Types: Types that depend on values (e.g.,
Vec[n, T]) -
Row Polymorphism: Extensible records with compile-time field tracking
-
Extensible Effects: User-defined, tracked side effects
-
WASM Target: Compiles to WebAssembly for portable execution
lower_ident = [a-z][a-zA-Z0-9_]*
upper_ident = [A-Z][a-zA-Z0-9_]*
row_var = ".." lower_identfn let const extern mut own ref type struct enum trait impl effect handle
resume handler match if else while for return break continue in
true false where total module use pub as unsafe assume transmute
forget Nat Int Bool Float String Type Rowint_lit = [0-9]+ | 0x[0-9a-fA-F]+ | 0b[01]+ | 0o[0-7]+
float_lit = [0-9]+ "." [0-9]+ ([eE][+-]?[0-9]+)?
char_lit = "'" (escape | [^'\\]) "'"
string_lit = '"' (escape | [^"\\])* '"'
bool_lit = "true" | "false"
unit_lit = "()"Arithmetic: + - * / %
Comparison: == != < > <= >=
Logical: && || !
Bitwise: & | ^ ~ << >>
Type-level: -> => : /
Special: \ (row restriction)program = [module_decl] {import_decl} {top_level}
top_level = fn_decl | type_decl | trait_decl | impl_block | effect_decl
| const_decl | extern_fn_decl | extern_type_decltype_decl = [visibility] "type" UPPER_IDENT [type_params] "=" type_body
type_params = "[" type_param {"," type_param} "]"
type_param = [quantity] IDENT [":" kind]
kind = "Type" | "Nat" | "Row" | "Effect" | kind "->" kind
type_body = type_expr (* alias *)
| struct_body (* record *)
| enum_body (* variant *)
struct_body = "{" field {"," field} "}"
enum_body = ["|"] variant {"|" variant}
variant = UPPER_IDENT ["(" type_expr {"," type_expr} ")"] [":" type_expr]type_expr = type_atom
| type_expr "->" type_expr ["/" effects] (* function *)
| "(" [quantity] IDENT ":" type_expr ")" "->" type_expr ["/" effects]
| type_expr "where" "(" predicate ")" (* refinement *)
type_atom = PRIM_TYPE | UPPER_IDENT | TYPE_VAR
| UPPER_IDENT "[" type_arg {"," type_arg} "]"
| "own" type_atom | "ref" type_atom | "mut" type_atom
| "{" row_fields "}" (* record *)
| "(" type_expr {"," type_expr} ")" (* tuple *)
row_fields = field_type {"," field_type} ["," row_var]
| row_var
effects = effect_term {"+" effect_term}
effect_term = UPPER_IDENT ["[" type_arg {"," type_arg} "]"]fn_decl = [visibility] ["total"] "fn" LOWER_IDENT
[type_params] "(" [param_list] ")"
["->" type_expr] ["/" effects]
[where_clause] fn_body
param_list = param {"," param}
param = [quantity] [ownership] IDENT ":" type_expr
ownership = "own" | "ref" | "mut"
where_clause = "where" constraint {"," constraint}
constraint = predicate | TYPE_VAR ":" trait_bounds
fn_body = block | "=" exprexpr = let_expr | if_expr | match_expr | fn_expr
| handle_expr | return_expr | binary_expr
let_expr = "let" ["mut"] pattern [":" type_expr] "=" expr ["in" expr]
if_expr = "if" expr block ["else" (if_expr | block)]
match_expr = "match" expr "{" {match_arm} "}"
match_arm = pattern ["if" expr] "=>" expr [","]
fn_expr = "|" [param_list] "|" expr
| "fn" "(" [param_list] ")" fn_body
handle_expr = "handle" expr "with" "{" {handler_arm} "}"
handler_arm = "return" pattern "=>" expr [","]
| LOWER_IDENT "(" [pattern {"," pattern}] ")" "=>" expr [","]
block = "{" {statement} [expr] "}"
binary_expr = unary_expr {BINARY_OP unary_expr}
unary_expr = [UNARY_OP] postfix_expr
postfix_expr = primary_expr {postfix}
postfix = "." IDENT | "." INT | "[" expr "]" | "(" [args] ")"
| "::" UPPER_IDENT | "\\" IDENT
primary_expr = literal | IDENT
| "(" expr ")" | "(" expr {"," expr} ")"
| "[" [expr {"," expr}] "]"
| "{" [field_init {"," field_init}] [".." expr] "}"
| "resume" "(" [expr] ")"pattern = "_" (* wildcard *)
| IDENT (* binding *)
| literal (* literal match *)
| UPPER_IDENT ["(" pattern {"," pattern} ")"]
| "(" pattern {"," pattern} ")" (* tuple *)
| "{" field_pat {"," field_pat} [".." ] "}"
| pattern "|" pattern (* or-pattern *)
| IDENT "@" pattern (* binding with pattern *)effect_decl = [visibility] "effect" UPPER_IDENT [type_params]
"{" {effect_op} "}"
effect_op = "fn" LOWER_IDENT "(" [param_list] ")" ["->" type_expr] ";"trait_decl = [visibility] "trait" UPPER_IDENT [type_params]
[":" trait_bounds] "{" {trait_item} "}"
trait_bounds = UPPER_IDENT {"+" UPPER_IDENT}
trait_item = fn_sig ";" | fn_decl | assoc_type
impl_block = "impl" [type_params] [trait_ref "for"] type_expr
[where_clause] "{" {impl_item} "}"const_decl = [visibility] "const" LOWER_IDENT ":" type_expr "=" expr ";"A top-level const binding compiles to an immutable WebAssembly global. The
initializer expression must reduce to a Wasm constant expression (a literal
or a constant arithmetic combination thereof); non-constant initializers are
not yet supported by the linear-memory backend.
Both function names and const names are registered in the same codegen name environment so that later top-level declarations may refer to either kind of binding by name. See §8 (Codegen Module Environment) for the encoding and the current single-pass population order.
extern_fn_decl = [visibility] "extern" "fn" LOWER_IDENT
[type_params] "(" [param_list] ")"
["->" type_expr] ["/" effects] ";"
extern_type_decl = [visibility] "extern" "type" UPPER_IDENT
[type_params] ";"extern fn declares a function whose implementation is supplied by the host
environment at link time. The linear-memory WebAssembly backend lowers each
extern fn to an (import "env" "<name>" (func …)) entry; the import slot
is registered in the codegen name environment so call sites resolve through
call k exactly as for locally-defined functions (see §8).
extern type declares an opaque, host-provided type. It carries no runtime
representation and generates no Wasm artifact; the typechecker treats the
name as a nominal opaque type whose internal structure is unknown.
Both forms are terminated by ; and carry no body.
Γ ⊢ e : τ / ε Expression e has type τ with effects ε
Γ ⊢ τ : κ Type τ has kind κ
Γ ⊢ P true Predicate P is satisfied| Quantity | Meaning | Usage |
|---|---|---|
|
Erased |
Compile-time only, no runtime cost |
|
Linear |
Must use exactly once |
|
Unrestricted |
Use any number of times |
Algebra:
0 + q = q 0 * q = 0
1 + 1 = ω 1 * q = q
ω + ω = ω ω * ω = ω| Modifier | Meaning |
|---|---|
|
Owned value - caller transfers ownership |
|
Immutable borrow - cannot modify |
|
Mutable borrow - exclusive access |
Rules:
-
Owned values are consumed on use
-
Multiple
refborrows allowed simultaneously -
Only one
mutborrow at a time -
Borrows cannot outlive owner
Functions declare effects after /:
fn pure_fn(x: Int) -> Int / Pure { x + 1 }
fn io_fn() -> () / IO { println("hello") }
fn fallible() -> Int / Exn[Error] { throw(Error::new()) }
fn combined() -> () / IO + Exn[Error] { ... }Partial by Default:
-
Functions are partial by default (may not terminate)
-
totalfunctions must provably terminate
Types can depend on values:
type Vec[n: Nat, T: Type] =
| Nil : Vec[0, T]
| Cons(T, Vec[n, T]) : Vec[n + 1, T]
// Can only call on non-empty vectors
fn head[n: Nat, T](v: Vec[n + 1, T]) -> TConstrain types with predicates:
type PosInt = Int where (self > 0)
fn safeDiv(a: Int, b: Int where (b != 0)) -> IntExtensible records with row variables:
// Works on any record with 'name' field
fn greet[..r](person: {name: String, ..r}) -> String {
"Hello, " ++ person.name
}
// Add fields
fn addAge[..r](rec: {..r}) -> {age: Int, ..r} {
{age: 0, ..rec}
}
// Remove fields
fn removeName[..r](rec: {name: String, ..r}) -> {..r} {
rec \ name
}Γ, x :q τ₁ ⊢ e : τ₂ / ε
────────────────────────────────
Γ ⊢ fn(x: τ₁) => e : τ₁ -> τ₂ / εΓ ⊢ f : τ₁ -> τ₂ / ε₁ Γ ⊢ e : τ₁ / ε₂
──────────────────────────────────────────
Γ ⊢ f(e) : τ₂ / ε₁ + ε₂Γ ⊢ e : {ℓ: τ, ..r} / ε
────────────────────────
Γ ⊢ e.ℓ : τ / ε
Γ ⊢ e : {..r} / ε
───────────────────────────────
Γ ⊢ {ℓ: v, ..e} : {ℓ: τ, ..r} / εtype Nat // Natural numbers (0, 1, 2, ...)
type Int // 64-bit signed integers
type Float // 64-bit floats
type Bool // true | false
type String // UTF-8 string
type Char // Unicode scalar value
type Never // Uninhabited typeeffect IO {
fn print(s: String);
fn println(s: String);
fn readLine() -> String;
}
effect Exn[E] {
fn throw(err: E) -> Never;
}
effect State[S] {
fn get() -> S;
fn put(s: S);
}effect IO {
fn println(s: String);
}
fn main() -> () / IO {
println("Hello, AffineScript!")
}type Vec[n: Nat, T: Type] =
| Nil : Vec[0, T]
| Cons(head: T, tail: Vec[n, T]) : Vec[n + 1, T]
total fn head[n: Nat, T](v: Vec[n + 1, T]) -> T / Pure {
match v { Cons(h, _) => h }
}
total fn append[n: Nat, m: Nat, T](
a: Vec[n, T], b: Vec[m, T]
) -> Vec[n + m, T] / Pure {
match a {
Nil => b,
Cons(h, t) => Cons(h, append(t, b))
}
}type File = own { fd: Int }
fn open(path: ref String) -> Result[own File, IOError] / IO
fn read(file: ref File) -> Result[String, IOError] / IO
fn close(file: own File) -> Result[(), IOError] / IO
fn withFile[T](
path: ref String,
action: (ref File) -> Result[T, IOError]
) -> Result[T, IOError] / IO + Exn[IOError] {
let file = open(path)?;
let result = action(ref file);
close(file)?;
result
}fn greet[..r](person: {name: String, ..r}) -> String / Pure {
"Hello, " ++ person.name
}
fn addField[..r](rec: {..r}, age: Int) -> {age: Int, ..r} / Pure {
{age: age, ..rec}
}
fn main() -> () / Pure {
let alice = {name: "Alice", role: "Engineer"};
let bob = {name: "Bob", dept: "Sales"};
// Both work despite different shapes
greet(alice); // "Hello, Alice"
greet(bob); // "Hello, Bob"
}effect State[S] {
fn get() -> S;
fn put(s: S);
}
fn counter() -> Int / State[Int] {
let n = State.get();
State.put(n + 1);
n
}
fn runState[S, T](init: S, comp: () -> T / State[S]) -> (T, S) / Pure {
handle comp() with {
return x => (x, init),
get() => resume(init),
put(s) => resume(())
}
}| AffineScript | WASM |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This section specifies how top-level declarations populate the binding environment that subsequent declarations and their bodies resolve against. It complements §3 (which gives the typing judgements) by fixing the operational rules every conforming code generator must obey, independent of any concrete target.
The WebAssembly realisation of these rules is documented at
docs/specs/codegen-environment.adoc.
A program is an ordered sequence of top-level declarations. Each kind contributes to the binding environment as follows:
| Declaration | Binds | Runtime artefact |
|---|---|---|
|
|
Yes — function in the target module |
|
|
Yes — host-supplied import |
|
|
Yes — same-type immutable cell |
|
|
No — compile-time only |
|
|
No — compile-time only |
|
|
No — handled by effect lowering (§7) |
|
|
No — compile-time only |
|
Trait dictionary for |
No — compile-time only |
Top-level declarations are processed in source order. The binding
environment in scope when a declaration is processed contains exactly
the names of declarations that precede it in source order, together
with the names introduced by the module’s use clauses (§8.4).
A reference to a name not yet bound at its use site is a static error:
fn use_pi() -> Float { pi } // ERROR: `pi` not yet declared
const pi: Float = 3.141592;Reordering resolves it:
const pi: Float = 3.141592;
fn use_pi() -> Float { pi } // OK: `pi` is in scope hereThe recommended source ordering — types, effects, constants, traits, impls, functions — is sufficient (though not necessary) to avoid every forward-reference error.
A bare identifier x in expression position resolves by the following
lookup order, in the context where the expression appears:
-
Local bindings introduced by enclosing
let, lambda parameters, or function parameters. -
Variant constructors of any in-scope
enumtype, resolved to their tag (§2.2). -
Top-level bindings:
fn,extern fn, andconstnames registered under §8.1.
A call expression f(args) resolves f against the same environment.
A name bound to a const may appear in expression position only; a
name bound to a fn or extern fn may appear in either expression or
call position. The well-formedness of these positions is established by
the type system (§3); a backend may rely on the typechecker having
rejected ill-positioned references.
Imports (use M::{…}, use M::*) extend the binding environment of
the importer with the public top-level names of the imported module,
under the alias chosen by the import form (§2.1). The order in which
import-introduced names enter the environment is before every
local top-level declaration of the importer.
Items that flow across module boundaries:
-
fnandextern fn— imported as references to the original definition. -
const— its initialiser is compiled inline into the importer’s module, so each importer materialises its own copy of the value. The denotation seen at use sites is the same as the sourceconstin the defining module.
A conforming code generator MUST:
- C1
-
Process top-level declarations in source order.
- C2
-
Register a top-level name in its environment before generating the body of any later declaration that may reference it.
- C3
-
For each runtime-bearing declaration (
fn,extern fn,const) emit an artefact whose denotation matches §2 and §3 — functions are first-class values, constants are immutable cells of their declared type. - C4
-
For each compile-time declaration (
type,extern type,effect,trait,impl) record sufficient information in the environment for subsequent declarations to typecheck and lower correctly, without necessarily emitting any target artefact. - C5
-
Report a static error (rather than emit a malformed module) when an identifier escapes its lexical scope or names a binding not yet in the environment under C1–C2.
- C6
-
If a binding kind is unsupported by the target, raise
UnsupportedFeatureat the declaration site, never silently drop it.
Implementations MAY use any internal representation for the binding environment; C1–C6 fix what is observable, not how it is stored.