Skip to the content

Surf

Surface language specification, v0

The specification of the Surf language: files, types, statements, components, markup and modules.This page as Markdown
Statusdraft
Editionnone yet. Version 0 has no stability guarantee.
2026-11-04: the details ofLocale (ADR-0132). A module constant cannot read Locale (7.3, 14). A call of a function prop reads Locale when a function value of its type reads it (10.2). Locale.set is a write (10.4, 14). locales = [] is an error, the lookup of RFC 4647 finds the locale, and a request after Locale.set has the header x-wave-locale (14). The three sorts use one stable merge sort on each target (4.5). 2026-11-04: Locale is valid in each function (7.3, 10.2, 14, ADR-0132). A project with Locale needs the key locales. A request of a server has its own locale, and Locale.set is not saved (14, ADR-0132). sortWith (4.4, 4.5, 6.4, ADR-0132). == on a dyn value is a compile error, and == on a type parameter needs Equatable (5.1, 7.3, 7.8, 7.9, ADR-0135). A module constant gets its value at its first read (7.3, ADR-0131). 2026-11-04: the decisions for version 1 (18). Traits and impl blocks (7.8, ADR-0134). Type parameters with bounds, generic components and trait objects (7.9, ADR-0135). The changes for both are also in 3.2, 4.1, 4.5, 5.1, 5.4, 6.2, 7.1, 7.3, 9.1, 10.2 and 16. No function types, traits or type parameters in extern modules (13.1, ADR-0133). The module Locale (7.2, 14, ADR-0132). The second section 13.3 (Calls) is now 13.4. 2026-11-03: a runtime error in a mount and in on error shows in a development build (15.2, ADR-0122). 2026-11-03: a module constant has one value (7.3, ADR-0118). 2026-11-03: a name cannot contain __ (3.1, ADR-0115). 2026-11-03: Span, a Link in a Text, and one text block for each Text (9.3, 9.6, ADR-0111). 2026-11-03: Window (14, ADR-0106), the module surf:ui (7.2, ADR-0102), Focus.to and Focus.newId (9.7, ADR-0102), the high contrast modes of ThemeMode (ADR-0103), and motion (7.5, ADR-0104). 2026-11-02: the syntax review: ; and match commas (3.5, 6.3, ADR-0094), .surfs for modules (2.2, ADR-0093), $ only for tokens (3.1, ADR-0095), conditions in style text (9.2, 12, ADR-0096), number marks and enum attributes (3.3, 4.7, ADR-0099). 2026-11-01: generic records and enums, and tuples (7.3, ADR-0087). 2026-11-01: Theme.setScale (ADR-0085). 2026-11-01: Set<T> and {...value} (ADR-0083), and Int64 (ADR-0084). 2026-11-01: generic functions (7.3, ADR-0080). 2026-11-01: the functions of dates (ADR-0079). 2026-11-01: File.pick (ADR-0078). 2026-11-01: Http.event (ADR-0077). 2026-11-01: names in other scripts (3.1, ADR-0076). 2026-11-01: Storage (ADR-0074), and on key (7.5, ADR-0075). 2026-11-01: #try (9.5, 15.2, ADR-0073). 2026-11-01: packages and pkg: imports (7.2, ADR-0071), surf lint (3.1, 9.5, ADR-0070), and the module surf:test (7.2, ADR-0069). 2026-10-31: Json.embed, Theme.designWidth, and events and calls for each app in a process (13, ADR-0056). 2026-10-31: the variant form style:{value}={{ ... }}, and style text can have line ends (2, 9.2, ADR-0054). 2026-10-30: a function value can be each kind of value, and it only reads (6.4, 10.3, ADR-0039). 2026-10-29: style: directives and computed values in style on an instance of a user component (9.2, Phase 3, P3.11).
2026-10-29on error(message) in the root component (15.2, Phase 3, P3.11).
2026-10-29: imports from subfolders, and a unique name for each component (7.2, Phase 3, P3.11).
2026-10-29: the pointer eventspointerdown, pointermove, pointerup and wheel with the built-in record Pointer (component reference 3.1, Phase 3, P3.2).
2026-10-29: the standard library for apps (Phase 3, P3.1): math,format, replace, indexOf, join, any, all, sum, Float.pi, Time.now() and Random. The standard library lists the rules.
2026-10-29surf extern stubs writes the stubs of the implementation files (13.2).
2026-10-29: an initial value cannot call a function of the component (7.5). Before, the compiler accepted the call, and the generated code failed.
2026-10-29List is virtualized, has the list role, and takes rowHeight (9.6, ADR-0027).
2026-10-29: theTimer module is removed. The built-in interval(ms) ends at the next multiple of ms on the clock, so a loop with it does not drift (7.5, 8, ADR-0026).
2026-10-29Int.parse and Float.parse read a number from a string (4.2).
2026-10-29: the bounds ofslice, get outside a list, and split("") are the same on all targets (4.4, 4.5). Before, the web and the native targets gave different results.
2026-10-29: the features that the compiler of version 0 does not have yet are marked "Not in version 0" (3.1, 6.4, 7.2, 7.5, 9.2, 9.5, 9.6, 13.2, 15.2).Limits of version 0 lists them on one page. panic ends a path like return (7.3). The lambda of a list method can read a let variable (6.4). Text can go into each slot that is inside a component that accepts text, not only the default slot (9.3).
2026-10-29: the rules ofchars, remove, sort and sortBy. enumerate is removed: version 0 has no tuples, and a for loop gives the index (4.4, 4.5).
2026-10-28: a component file has no<script> tag. A line --- separates its declarations from its markup (2.3). Comments are //, /* */ and /// everywhere (2.4). An on: attribute takes statements (6.4, 9.2). Blocks are #if, #for, #match, #await and #fill with a body in braces, and tags are @const and @slot (9.5). ADR-0021 gives the reasons.
2026-10-28: utilities replace the<style> section, the style attributes and the class names (ADR-0020). testid is new. Box, the variant and tone of Text, and #platform blocks are removed (2.3, 9.2, 9.5, 9.6, 12, 14, 16).
2026-10-25: clarifications from Spike A (SPIKE-A.md, sections 4 and 7.4). They define these rules:
2026-10-26: P1.7 sets the web implementation file of Phase 1 to.web.js and gives the JavaScript values at the boundary (13.2, 13.4).
2026-10-26: P1.6 adds the rules of section 8 for the position ofawait, spawn, sleep and the start of a task. #await takes a Task<T>? (9.5).
2026-10-26: P1.9 names the theme tokens (12.2). The web stylesheet is in the layout spec, section 10.
2026-10-26: P1.8 adds thecomponent reference (9.2, 9.6).
2026-10-25: P1.5 adds the map methods and the equality of maps (4.5).
2026-10-25: P1.4 adds three rules. The first rule tells wherecleanup is valid and what it can use (7.5). The others are about events from a component that leaves the tree (7.6), and writes that are not reads (10.2).
2026-10-25: the slot example of 7.7 declaresrow with a bindable parameter, and the parent binds items. Its fill binds todo.done, and a fill parameter without bindable is a constant. P1.3 found the conflict.
This document specifies the .surf language: the file structure, Surf Script, the markup, the reactivity model, the styles and the extern modules. The Surface Layout Spec is a separate document. It has the utilities. Section 12 gives only a summary of them.

1. Conventions

  • must and must not state a rule. A violation of a rule is a compile error, unless the rule says "runtime error".
  • can states a permission.
  • A runtime error has the same cause and the same result on all targets (section 15).
  • Grammar fragments use this notation: ? is optional, * is zero or more, + is one or more, | separates alternatives, and "x" is literal text.
The design rule behind every decision in this spec: one source, one meaning, on every target. If a feature cannot have the same meaning in JavaScript and in native code, the feature is not in the language.

2. Source files

2.1 Encoding

Source files are UTF-8. Line ends can be LF or CRLF. The formatter writes LF.The formatter writes 4 spaces for each indent level. A project can set another indent in surf.toml (ADR-0162):
toml
[fmt]indent = 2
  • The value is a number of spaces from 1 to 8.
  • A file uses the surf.toml of the nearest folder above it. Thus a package in src/pkg keeps its own indent. A file with no surf.toml above it gets 4 spaces.
  • surf fmt, surf fmt --check and the format action of the language server use the indent of the project. The indent of the project wins over the settings of the editor.
  • The indent does not change the meaning of a file. The formatter does not use tabs.

2.2 File kinds

Kind
File name
Content
Component file
Has the extension .surf. A name in PascalCase, for example Counter.surf or TodoRow.surf
Declarations in Surf Script, then markup
Module file
Has the extension .surfs. A name in camelCase, for example model.surfs or todoRow.surfs
Declarations in Surf Script only
The extension gives the kind of a file (ADR-0093). The name of a component is its file name without the extension. A component file must contain markup. A module file must not contain markup.File names (ADR-0140):
  • A component file starts with an uppercase letter, and a module file starts with a lowercase letter. A capital letter marks each new word. A name has no _ and no -.
  • A wrong name is a compile error, and the message gives the correct name. For example, the message for todo_row.surfs gives todoRow.surfs, and the message for todo-row.surf gives TodoRow.surf.
  • Two files with paths that differ only in case are a compile error, for example todoRow.surfs and todorow.surfs. macOS and Windows read the two paths as one file.
  • The rule applies to the part before the first dot. The parts after a dot keep their rule, for example .server in hooks.server.surfs and .remote in todos.remote.surfs (wave spec 1). The @ of Page@.surf stays valid.
  • Two files cannot have one module name on a native target, for example TodoRow.surf and todoRow.surfs (ADR-0115).
  • The names of folders and of assets have no such rule.

2.3 Sections of a component file

A component file has two sections, in this order:
  • The declarations of the component, in Surf Script. They are also called the script of the component.
  • The markup. It is required.
A line that has only --- separates them. A component without declarations has no ---: its file is markup only.
surfRun
prop label: String = "Clicks";state count = 0;---<Button on:press={count += 1}>{label}: {count}</Button>
A component file is a module file with markup at its end. It has no <script> tag and no <style> section. The styles are utilities in the markup (section 12). A <script> tag is a compile error with a help text (ADR-0021).

2.4 Comments

The comments are the same in the declarations and in the markup:
Comment
Meaning
// text
A comment to the end of the line
/* text */
A comment over one or more lines. Block comments do not nest.
/// text
Documentation of the declaration on the next line. At the top of a component file, documentation of the component. The language server shows it.
In the markup, a comment starts at the start of a line or after a tag: <Text>http://example.com</Text> is text. <!-- --> is a compile error with a help text. A comment between the attributes of a tag is a compile error: write it on the line above the tag.

3. Lexical structure

3.1 Identifiers

An identifier starts with a letter of any script or _. Its other characters are letters, digits, combining marks and _ (ADR-0076). $ marks a theme token, and a name cannot start with $ (ADR-0095).A name cannot contain __ (two _ in a row). The compiler uses these names, so a name of a program never meets a name of the compiler (ADR-0115). This rule is also true for a field, so a JSON key such as __typename cannot be a field. Json.decode ignores a key that the record does not have.
  • Component names, type names and enum names must start with an uppercase letter.
  • Variables, functions, fields, props and enum cases start with a lowercase letter. surf lint warns about other names (ADR-0070).

3.2 Keywords

text
as async await bindable break case const continue derived dyn effect elseemit enum event extern false fn for if impl import in let match module mutnone on prop pub return self Self slot spawn state trait true type var while
var is reserved. It is not a declaration: use let for a variable (ADR-0013).case and as are reserved for later versions.dyn, impl, mut, self, Self and trait are the keywords of traits (section 7.8, ADR-0134) and of trait objects (section 7.9, ADR-0135), as in Rust.Each keyword is valid as a field name, also the six keywords of traits (ADR-0134). So a record reads the JSON keys type, for and self. A field name is the name in a record type, in a record literal and after .. A local variable, a function and a type cannot have a keyword name.
surf
pub type Entry = { type: String, for: String, self: Bool }pub fn mine(e: Entry) -> Bool = e.self && e.type == "user";
These words are contextual keywords. They have a special meaning only in one position. In all other positions they are valid identifiers:
  • component at the start of a declaration in a component script (section 7.9)
  • motion at the start of a declaration in a component script
  • sequence at the start of a declaration in a component script (section 7.5a)
  • cue at the start of a line in the body of a sequence (section 7.5a)
  • mount, destroy and error after on
  • cleanup in an effect
  • fallback in an extern module
  • key in the head of a #for block
  • else, empty, then and catch after the body of a block in the markup

3.3 Literals

Type
Examples
Rules
Int
42, 1_000_000, 0xFF, 0b1010
_ can separate digits.
Float
1.5, 0.25, 2e10, 1.5e-3, 1f64
A Float literal must have digits before the point. .5 is an error. The mark f64 makes a number a Float.
Int64
1i64, 9007199254740993i64
The mark i64 makes a number an Int64 (ADR-0099). A hex or a binary literal has no mark.
String
"hello", "{count} items"
See section 3.4.
Bool
true, false
Optional
none
The empty value of an optional type.

3.4 Strings

  • A string literal is in double quotes.
  • {expression} inside a string inserts the value of the expression (section 5.4 gives the conversion rules).
  • Escapes: \n, \t, \r, \", \\, \{, \}, \u{1F600}.
  • A string literal cannot contain a raw line end. Use \n. Style text is the exception: in style, a line end is white space between two items (section 12).

3.5 Statement ends

A ; ends each statement and each declaration on one line (ADR-0094). A line end has no meaning in script, so a statement can use more than one line.
  • A form that ends with } has no ;: fn with a block, if, for, while, match, effect, cleanup, on ..., type, enum and extern module.
  • A declaration or a statement that ends with } for another reason needs its ;, for example const a = { x: 1 }; and const b = match c { ... };.
  • The last statement of an on: value has no need for ;: on:press={n += 1}, on:press={save(); close()}.
  • @const has no ;. The line end ends a tag.

4. Types

4.1 Overview

Type
Meaning
Int
Integer in the range −(2^53 − 1) to 2^53 − 1 (section 4.2, ADR-0010)
Float
IEEE 754 binary64 number
Bool
true or false
String
Immutable sequence of Unicode scalar values
Bytes
Immutable sequence of numbers from 0 to 255 (ADR-0037)
Color
A color that the compiler, Color.parse, Color.rgb or Color.mix checked. Text in quotes is a Color where the type says so (ADR-0050, ADR-0160)
Scalar
Built-in enum with the cases text(String), number(Float) and bool(Bool), for a JSON value whose kind only the data gives (section 4.7, ADR-0045)
Void
The return type of a function that returns no value
List<T>
Ordered sequence of values of type T
Map<K, V>
Map from keys of type K to values of type V
T?
Optional value: a T or none
Result<T, E>
Built-in enum with the cases ok(T) and err(E)
Task<T>
The result of an async fn
Ordering
Built-in enum with the cases less, equal and greater. The result of compare of the trait Comparable (section 7.8, ADR-0134)
(A, B) -> R
Function type
Record types
Declared with type (section 4.6)
Enum types
Declared with enum (section 4.7)
dyn Trait
A trait object: a value of each type with an impl of the trait (section 7.9, ADR-0135)
impl Trait
The short form of a type parameter with a bound (section 7.9, ADR-0135)
There is no any type, no null other than none, and no implicit conversion between types.

4.2 Int

  • The range of Int is the same on all targets: −(2^53 − 1) to 2^53 − 1. JavaScript can represent every value in this range exactly.
  • A result outside the range is a runtime error ("the result is outside the integer range"). The compiler removes the check where it can prove that the result is in the range.
  • a / b with two Int values gives an Int. The result is truncated toward zero.
  • a % b gives the remainder. The sign of the remainder is the sign of a.
  • Division or remainder by zero is a runtime error.
  • Conversion: i.toFloat(), f.toInt() (truncation), f.round(), f.floor(), f.ceil(). Each conversion from Float gives a runtime error for NaN and for a result outside the Int range.
  • Int.parse(s) gives Int?: the value of s, or none. s must be an optional + or - and the digits 0 to 9, with no other characters. A value outside the Int range gives none.
  • Float.parse(s) gives Float?. s must be an optional sign, digits with an optional . and fraction, and an optional exponent (e or E, an optional sign, digits). It needs at least one digit before the exponent. White space, inf, NaN and hexadecimal numbers give none, and so does a value that is too large for a Float. Use s.trim() first for the text of an Input.
  • f.round() rounds a value that is exactly halfway between two integers away from zero: 2.5.round() is 3, and (-2.5).round() is -3. JavaScript Math.round rounds -2.5 to -2, so web builds use their own function for round.

4.3 Float

  • Float follows IEEE 754 binary64 on all targets. Division by zero gives infinity or NaN, as IEEE 754 specifies.
  • Conversion to String uses the shortest text that converts back to the same value. The format is the format of ECMAScript Number.prototype.toString. Native builds implement the same algorithm, so "{x}" gives the same text on all targets.
  • An Int and a Float cannot be mixed in one operation. 1 + 1.5 is a compile error. Write 1.toFloat() + 1.5 or 1.0 + 1.5.

4.4 String

  • A String is a sequence of Unicode scalar values. Native builds store UTF-8. Web builds store JavaScript strings.
  • There is no index operator, because the index of a character is different in UTF-8 and UTF-16.
  • Operations count and slice by Unicode scalar values on all targets:
  • s.count(), s.isEmpty(), s.slice(from, to), s.chars(). chars() gives a List<String> with one string for each scalar value.
  • s.contains(t), s.startsWith(t), s.endsWith(t), s.split(t), s.trim()
  • s.upper() and s.lower() use the Unicode default case mapping, independent of the locale.
  • s.slice(from, to) keeps each bound between 0 and s.count(), and to is at least from. So "abc".slice(-1, 9) is "abc", and "abc".slice(2, 1) is "".
  • s.split("") gives one string for each scalar value, as chars() does.
  • a + b joins two strings.
  • <, <=, >, >= compare by scalar values on the native targets and in the interpreter. The web compares by UTF-16 code units, as JavaScript does, because it is faster (ADR-0113). The same rule applies to compare of String (section 7.8), sort and sortBy. The two orders differ only in one case. At the first place where the two strings differ, one character is above U+FFFF and the other is from U+E000 to U+FFFF. Most emoji are above U+FFFF. docs/native-differences.md lists this difference.
  • == and != compare the scalar values on all targets.
  • The order of a language comes from Locale.compare (section 14), for example in sortWith (section 4.5).
  • On the web, the runtime replaces each lone surrogate that comes from a browser API with U+FFFD.

4.5 List and Map

  • A list literal is [1, 2, 3]. An empty list needs a known type: state todos: List<Todo> = [].
  • xs[i] reads element i. An index outside the list is a runtime error. xs.get(i) gives T?: none for an index outside the list, also for a negative index.
  • xs.slice(from, to) keeps its bounds in the list, as String.slice does (section 4.4).
  • Methods that change the list need a place (section 5.2): push, insert, remove, removeAt, clear, sort, sortBy, sortWith. Each is a statement of its own.
  • xs.remove(v) removes the first element that is equal to v. Without such an element, the list does not change.
  • xs.sort() orders a list by <. xs.sortBy(x => key) orders any list by a key, for example todos.sortBy(t => t.title). The type of the elements of sort and the type of the key must have the trait Comparable (section 7.8, ADR-0134).
  • xs.sortWith((a, b) => order) orders any list with a compare function of the type (T, T) -> Ordering (ADR-0132). The result .less puts a before b. An order that no key gives needs this method, for example the order of a language: names.sortWith((a, b) => Locale.compare(a, b)) or users.sortWith((a, b) => Locale.compare(a.name, b.name)). The argument is a lambda with two parameters, or another function value of this type.
  • The three sorts are stable: equal elements keep their order. For sortWith, two elements are equal when the compare function gives .equal.
  • Each runtime uses the same stable merge sort for the three sorts, also the web (ADR-0132). So each target gives the same order, also when the order is not consistent. An example is a compare function or an impl of Comparable that gives .less for (a, b) and also for (b, a).
  • The merge sort has this exact form, so each target calls the compare function with the same pairs, in the same order:
  • sortBy gets the key of each element one time, in the order of the list, before the sort starts.
  • The sort starts with runs of one element. A pass merges each two adjacent runs into one run, from the start of the list to its end. A run at the end with no partner stays as it is. The next pass has runs of double the length. The sort stops when one run holds the whole list.
  • A merge compares two elements: a, the next element of the left run, and b, the next element of the right run. It takes b only when b must come before a. Else it takes a. When one run has no more elements, the merge takes the rest of the other run.
  • b must come before a in these cases. For sortWith, the compare function gives .greater for (a, b). For sort and sortBy of a type with an impl of Comparable, compare gives .greater for (a, b), or for their keys. For the other types, b < a, or the key of b < the key of a. So NaN never comes before another value in a merge.
  • Methods that return a new value: map, filter, find, count, isEmpty, contains, first, last, get, slice. A loop gets the index of each element with for item, index in xs (section 6.1).
  • remove, contains and indexOf compare with ==. A list of dyn values cannot use them, because a dyn value has no == (section 5.1, ADR-0135).
  • A map literal is Map { "a": 1, "b": 2 }. The key type must have the trait Hashable (section 7.8, ADR-0134): Int, String, Bool, or a record or an enum whose fields all have Hashable. A dyn type cannot be a key (section 7.9).
  • m[k] reads a value of type V?. m[k] = v adds or replaces a value. A new key goes to the end. A replaced value keeps the position of its key.
  • Map methods: count, isEmpty, contains(k), keys() and values() return values. remove(k) and clear() change the map and need a place.
  • Two maps are equal when they have the same keys with equal values. The order of the keys does not count.
  • Iteration over a map follows the order in which the keys were first added.

4.6 Records

surf
type Todo = { id: Int, title: String, done: Bool = false }
  • A record type is nominal. Two record types with the same fields are different types.
  • A field can have a default value.
  • Two fields with one name are a compile error.
  • A record cannot hold a value of its own type, also through an optional value, a tuple or another record or enum: type Node = { next: Node? } is a compile error. A List or a Map can hold it: type Node = { children: List<Node> }. A native build gives each field a fixed size.
  • Literal: Todo { id: 1, title: "Buy milk" }. When the expected type is known, the type name can be left out: { id: 1, title: "Buy milk" }.
  • Copy with changes: { ...todo, done: true }.

4.7 Enums

surf
enum Tone { info, warning, error }enum Shape { circle(radius: Float), rect(width: Float, height: Float) }
  • Cases start with a lowercase letter. Two cases with one name, and two fields of a case with one name, are compile errors.
  • The field of a case cannot hold its own enum outside a List or a Map, as for a record (4.6).
  • A case is written Shape.circle(radius: 2.0), or .circle(radius: 2.0) when the expected type is known.
  • A prop with an enum type takes a case: tone={.warning}. The prop gives the enum, so the parent needs no import of it. Text is a compile error (ADR-0099).
  • Scalar is a built-in enum (ADR-0045). Json.decode takes its case from the value, and value.text() gives its text.

4.8 Optional values

  • T? is a T or none.
  • An optional of an optional is a compile error. The web target has one none, so it cannot keep the two levels apart. xs.get(i), xs.first(), xs.last(), xs.find(f) and m[k] of a list or a map of optional values are compile errors: check the index or the key first, or use a list without none.
  • a?.b gives none if a is none. a?.b(c) calls a method only when a has a value (ADR-0052).
  • a ?? b gives b if a is none. With an optional b, the result is an optional: m[k] ?? none.
  • if let x = opt { ... } runs the block only when opt has a value.

4.9 Result and errors

  • Result<T, E> is a built-in enum with the cases ok(T) and err(E).
  • A Result that nothing reads is a compile error. const _ = save() drops it (ADR-0052).
  • In a function that returns Result<_, E>, the postfix operator ? returns the err value at once, or gives the ok value.
  • There are no exceptions. A bug that cannot continue uses panic("message"), which is a runtime error.

4.10 Type inference

  • The compiler infers the types of const, let, state and derived declarations.
  • These must have explicit types: prop declarations, function parameters, the return types of pub functions, record fields and extern declarations.

5. Values

5.1 Value semantics

Every value in Surf Script is a value, not a reference (ADR-0009).
  • An assignment, a function argument and a prop give the receiver its own copy.
  • A change to a copy does not change the original.
  • == compares two values field by field and element by element. Function values cannot be compared. f == g is a compile error.
  • An impl of the trait Equatable replaces this comparison for its type (section 7.8, ADR-0134).
  • A dyn value cannot be compared. a == b on two dyn Shape values is a compile error, because Equatable cannot be dyn (section 7.9, ADR-0135).
  • There is no reference identity in the language.
Implementations do not copy at every assignment. Native builds use reference counting with copy-on-write. Web builds insert copies only where a change through one name could be visible through another name. Because there are no references, values cannot form cycles, and reference counting never leaks memory.

5.2 Places

A place is an expression that can receive a value:
  • a let variable
  • a state or a bindable prop (section 7)
  • a field of a place: todo.title
  • an element of a list place: todos[i]
  • an entry of a map place: scores["ana"]
  • the variable of a #for block over a place (section 9.5)
Assignments (=, +=, -=, *=, /=, %=) and the changing methods of lists and maps need a place on the left side.

5.3 Equality of primitive writes

A write of an Int, Float, Bool or String value that is equal to the current value does not count as a change (section 10.4). A write of a list, map, record or enum value always counts as a change.

5.4 Conversion to text

{expression} in a string or in markup text accepts each type with the trait Text (section 7.8, ADR-0134). String, Int, Float and Bool have it. For another type, {x} gives the result of x.text(). Other types are a compile error.

6. Statements and expressions

6.1 Statements

Statement
Example
Constant
const total = a + b
Variable
let i = 0
Assignment
count += 1
Condition
if x > 0 { ... } else if x < 0 { ... } else { ... }
Optional test
if let user = maybeUser { ... }
List loop
for todo in todos { ... }
Loop with index
for todo, i in todos { ... }
Map loop
for key, value in scores { ... }
Range loop
for i in 0..10 { ... } (excludes 10), for i in 0..=10 { ... } (includes 10)
Condition loop
while queue.count() > 0 { ... }
Loop control
break, continue
Return
return value
Event
emit submit(draft) (section 7.6)
Background task
spawn load() (section 7.5)
An assignment is a statement, not an expression. a = b = 0 is a compile error.A for loop over a list iterates over the list as it was when the loop started (section 5.1):
  • The loop variable is a copy of the element. A write to the list in the body does not change the loop variable.
  • A write to the list in the body does not change the number of iterations or the elements that the loop sees. Examples of such writes are todos.push(x) and todos[0].done = true. The next statement after the loop sees all the writes.
  • A for loop over a map follows the same rule.
surf
for todo in todos { todos.push({ ...todo, id: nextId() }); // runs once for each element of the original list}

6.2 Operators

From the highest to the lowest precedence:
Level
Operators
Notes
1
. ?. () [] postfix ?
Member, call, index, error propagation
2
prefix - !
3
* / %
4
+ -
+ also joins strings
5
.. ..=
Ranges of Int
6
< <= > >=
7
== !=
Value equality (section 5.1)
8
&&
9
||
10
??
11
cond ? a : b
12
=>
Lambda
Binary operators at the same level group from left to right. ??, ? : and => group from right to left.A type with an impl of an operator trait can use the operator of the trait (section 7.8, ADR-0134). For example, a record Vector can use a + b. The operators of levels 6 and 7 come from Comparable and Equatable.

6.3 Match

surf
const area = match shape { .circle(r) => 3.14159 * r * r, .rect(w, h) => w * h,};
  • match is an expression.
  • Each arm ends with ,. The last arm can leave it out (ADR-0094).
  • Patterns: literals, enum cases with bindings, none, and _ for any value.
  • A match must cover every case. A missing case is a compile error.

6.4 Lambdas

  • x => x * 2
  • (a, b) => a + b
  • (g) => { ...statements... }
A lambda captures const values and the reactive sources of its component (section 10.1). A lambda cannot capture a let variable. This rule prevents shared mutable state between a function and its creator. The lambda of filter, find, map, sortBy and sortWith is an exception: it runs before the call returns, so it can read a let variable.The body of a lambda cannot be an assignment: x => total += x is a compile error. Use a block: x => { total += x; }. An on: attribute is different: its value is statements (section 9.2).A function value is a value (ADR-0039). It can be a local constant, a parameter, a result, a field of a record, an item of a list or a map. It can also be a state, a derived or a prop. A module function is a value by its name. Each expression of a function type can be called: f(1), action.run(1), fs[0](1), adder(1)(2).
  • A function value only reads. It cannot write to a state, call a function of a component or use await. Its block can write its own let variables. A child tells its parent with an event (section 9.2).
  • A lambda with a block needs a known type: const f: (Int) -> Int = x => { return x + 1; }. Each path of a block with a result must return.
  • A function value is not a key of a Map. JSON and extern modules refuse it. An async fn is not a function value.

6.5 Calls and named arguments

surf
fn circle(x: Float, y: Float, r: Float, fill: Color = "#007aff", stroke: Color? = none) { // ...}fn dot(x: Float, y: Float) { circle(x, y, 3.0, fill: "#ff9500");}
  • Parameters without a default value are positional.
  • Parameters with a default value must be named in the call.

7. Declarations

7.1 Where each declaration is valid

Declaration
Module file
Component script
Inside a function
import
yes
yes
no
const
yes (module constant)
yes (instance constant, which can read the props. pub const is a module constant)
yes (local constant)
type, enum
yes
yes
no
fn, async fn
yes
yes
no
trait, impl (section 7.8)
yes
no
no
component (section 7.9)
no
yes
no
extern module
yes
no
no
prop, bindable prop
no
yes
no
state, derived, motion, effect
no
yes
no
sequence (section 7.5a)
no
yes
no
event, slot
no
yes
no
on mount, on destroy
no
yes
no
let
no
no
yes
A component script has no let. Mutable component data must be state, so every change is visible to the reactivity model.

7.2 Imports and visibility

surf
import Counter from "./Counter.surf";import { Todo, nextId } from "./model.surfs";import { Haptics } from "pkg:haptics";import close from "./icons/close.svg";
  • A path is relative to the importing file and must end with .surf, .surfs or .svg. A pkg: path names a package. A ~/ path starts at the folder src, for example ~/model.surfs. surf:wave, surf:test and surf:ui are built-in modules.
  • An import of an .svg file gives a constant of the built-in type Icon with the name of the import (ADR-0030). The compiler converts the file: a missing file and a part of the file that an icon cannot have are compile errors.
  • The files of a project can be in subfolders of its folder. A path starts with ./ (the folder of the importing file) or ../ (the folder above it), for example ./parts/Row.surf or ../model.surfs. A path that leaves the folder of the project is a compile error.
  • A component has the name of its file, without the folders. Two components with the same name in one project are a compile error, also in different folders.
  • A value of a record type of another file needs an import of the type. This is also true for the value of a prop of a child component.
  • pkg:name imports lib.surfs of a package, and pkg:name/x.surfs imports another file of it. surf.toml names the packages, and surf install copies each one to src/pkg/<name> (ADR-0071). A pkg: path to a package that is not there is a compile error.
  • The module surf:test has the functions of surf test (ADR-0069). surf test adds this module, so a build has no surf:test.
  • The module surf:ui has the standard components (component reference, section 5, ADR-0102). A named import takes each component and each type: import { Dialog, Tabs, UiItem } from "surf:ui";. Only the components of the import go into a build.
  • A component file exports only its component. The other declarations in a component script are private.
  • A module file exports only the declarations that start with pub.
  • Built-in components (section 9.6), Platform, Focus, Result, Task, Pointer, Icon, ThemeMode and the theme tokens need no import.
  • The built-in traits and Ordering (section 7.8), and Locale and its enums (section 14), need no import.
  • A call of a method of a trait of another file needs an import of the trait (section 7.8, ADR-0134). A method of a plain impl needs no import.
  • An import cycle is a compile error in version 0.

7.3 Constants and functions

surf
const MAX_ITEMS = 100;pub fn plural(n: Int, word: String) -> String { return n == 1 ? "1 {word}" : "{n} {word}s";}fn double(x: Int) -> Int = x * 2;
  • A const value is an expression of literals, other constants and calls of functions.
  • A module constant has one value. Each read gives the same value, also when the value comes from a call such as Random.float() (ADR-0118).
  • A constant with a value that is not a literal gets its value at its first read, on all targets (ADR-0131). A server has one value for the process. Not in version 0: the web computes the value when the page loads, and a server has one value for each thread (see native differences).
  • fn name(...) -> T = expression is the short form of a function with one expression.
  • In a function with a return type other than Void, every path through the body must end with return. A path that reaches the end of the body without return is a compile error. For this check:
  • An if with an else returns when both branches return. An if without an else does not return.
  • A loop does not return, because its body can run zero times.
  • A call of panic(message) as a statement returns, because it stops the batch (section 15.2).
  • A Void function can end without return.
  • A function in a module file cannot read the reactive sources of a component. It gets the data of a component through its parameters. It can read Locale (section 14, ADR-0132).
  • A module constant cannot read Locale, also through a call (ADR-0132). A module constant has one value, so it cannot follow Locale.set. This is a compile error. The message says to use a function in place of the constant, for example fn title() -> String. An instance constant of a component that reads Locale is also a compile error, and its message says to use a derived value.
Generic functions
surf
pub fn first<T>(items: List<T>) -> T? { return items.get(0);}
  • A function in a module file can have type parameters. A component file cannot have such a function (ADR-0080). A generic component has type parameters of its own (section 7.9).
  • A call has no list of types. The values of the call give the types, so each type parameter must appear in a parameter.
  • The body can copy a value of a type parameter and put it in a list. Other operations on it are compile errors.
  • A type parameter can have a bound: <T: Comparable>. The body can then use the methods and the operators of the traits of the bound (section 7.9, ADR-0135).
  • == and != on a value of a type parameter need the bound T: Equatable. contains, indexOf and remove on a list of such values need it too (section 4.5). A function type and a dyn type have no Equatable, so a call cannot give them to such a type parameter (section 7.9, ADR-0135).
  • A generic function is a value where the expected type gives each type parameter, as a function of Rust: const pick: (List<Int>) -> Int = largest;. The type of a parameter of a call can also give them: apply([1, 3], largest). Without such a type, a generic function as a value is a compile error. An async fn is not a value.
Generic records and enums, and tuples
surf
pub type Box<T> = { value: T }pub enum Either<L, R> { left(value: L), right(value: R) }fn sample() { const boxed: Box<Int> = { value: 7 }; const pair = (1, "one"); const (n, word) = pair;}
  • A record and an enum can have type parameters (ADR-0087). A type parameter of a record or an enum can have a bound too (section 7.9). Each use of the type must give a type with the traits of the bound.
  • (Int, String) is a tuple type with two to four values. (a, b) makes a tuple, and t.0 reads a value.
  • const (a, b) = value reads the values of a tuple.

7.4 Props

surf
prop label: String = "Clicks";prop items: List<Todo>;bindable prop value: String = "";
  • A prop without a default value is required. A missing required prop is a compile error in the parent.
  • A component cannot assign to a normal prop.
  • A component can assign to a bindable prop. If the parent used bind:value={place}, the assignment also changes the parent place. Without bind:, the assignment changes only the local value until the parent sends a new value.

7.5 State, derived values and effects

surf
state count = 0;state todos: List<Todo> = [];derived remaining = todos.filter(t => !t.done).count();effect { const r = room; Chat.join(r); cleanup { Chat.leave(r); }}on change(filter) { page = 0;}on mount { focusFirstField();}on destroy { saveDraft();}
  • A state initializer runs once, when the component instance is created. It can read props, but it does not follow later changes of the props.
  • The initial values of state, of an instance const and of a prop cannot call a function of the component. The state of the component does not exist yet at that time. A module function can get the props as arguments: state items = makeItems(count). A derived value can call a function of the component.
  • An initial value can read a derived value that comes before it. It then reads the expression of that value, so the rule above applies to that expression too (ADR-0104).
  • A derived value is an expression. It must not write a reactive source. A write is a compile error.
  • A derived value is calculated when something reads it, and it stays cached until one of its dependencies changes.
  • An effect body runs after the first render and again after every batch that changes one of its dependencies (section 10.5).
  • cleanup { ... } inside an effect runs before the next run of the effect and when the component is destroyed. It is valid only at the top level of the effect body, at most once. It can use the locals that the effect declares before it, with their values at the time when the effect reached cleanup.
  • An effect body is synchronous. It can start an async function with spawn f(). The runtime cancels the task when the effect runs again or when the component is destroyed. A task that on change starts goes on after the next change, as a task of an event handler does.
  • on mount runs once, after the first frame that shows the component. on destroy runs once, when the component leaves the tree.
Values that move in time
surfRun
state open = false;motion x(ms: 400, ease: "ease-spring") = open ? 240.0 : 0.0;---<Column style="translate-x-{x}px" />
motion declares a value that moves to its target in time (ADR-0104). The expression after = is the target, as the expression of a derived value is. An app sets a target with a write to a state or with a new prop.
  • The value is a Float, and each consumer reads it as it reads a state: a computed style, a derived value, an effect or a text. The target must be a Float.
  • ms is the time in milliseconds. ease is the name of an easing utility of the layout spec, section 12.4: "ease", "ease-linear", "ease-out", "ease-out-cubic", "ease-spring" or "ease-spring-0.4" (ADR-0104). A reader can then copy the name from a style. Both are optional and both are fixed in the source. The defaults are 150 and "ease".
  • A name without ease-, for example "spring" or "linear", is a compile error. The message gives the correct name, for example "ease-spring". The prop ease of Transition has the same rule (component reference 4.15).
  • The first value is the first target, with no movement. The target follows the rule of an initial value of a state: it cannot call a function of the component.
  • When the target changes, the value starts at its shown value. With a spring, it keeps its speed. With another easing, it starts with a speed of 0 and takes the whole time.
  • At the end of the time, the value is exactly the target. During the time, the value has at most 4 digits after the point, so that all targets show the same numbers.
  • A motion value cannot take a write. Write the state that its target reads.
  • The value changes once for each frame while it moves. Each change is a batch (section 10.5). A value that does not move uses no frames.
  • With the setting of reduced motion on, the value goes to a new target at once (layout spec 5.10).
  • The runtime stops a moving value when its component leaves the tree, as it cancels a task.

7.5a Sequences

surf
state ready = false;sequence intro(play: ready) { cue logo(ms: 400) = "fade"; cue title(gap: -200, stagger: 30) = "rise"; cue more(gap: -100) = "fade";}on mount { ready = true;}---<Column style="gap-4 items-center"> <Image src="assets/logo.svg" alt="Surface" style="cue-logo" /> <Text style="text-48 split-letters cue-title">One universal best option</Text> <Button style="cue-more">Read more</Button></Column>
A sequence orders the animations of several elements on one timeline (ADR-0152). It is the timeline form of version 1. Each cue is one animation of the sequence. An element plays a cue with the utility cue-<name> (layout spec 5.10).
  • sequence and cue are contextual keywords. sequence starts a declaration in a component script, and cue starts a line in the body of a sequence. Sections 3.2, 7.1 and 16 have these words.
  • A sequence is valid only in a component script. Its name is unique among the declarations of the component.
  • cue <name>(<arguments>) = "<keyframes>"; declares a cue. The text after = is a name of keyframes of style.toml, or of a built-in animation (layout spec 2.1). An unknown name is a compile error.
  • The arguments of a cue have fixed values in the source. Each argument is optional:
Argument
Meaning
Default
ms
The time of the cue for one element, in ms
The ms of the keyframes
ease
The name of an easing utility, as ease of motion (section 7.5)
The ease of the keyframes
gap
The time from the end of the cue before, in ms. A negative value starts the cue earlier, so the two cues overlap
0
with
The name of an earlier cue of the sequence, for example with: logo. The cue then starts at the start of that cue, plus gap
none
stagger
The extra time for each element of the cue, in ms
0
from
The first element of the stagger: "start", "end" or "center"
"start"
The elements of a cue.
  • The elements of a cue are the elements of the component with its utility cue-<name>, in tree order. The style of an instance gives the utility to the root elements of the instance (section 12).
  • The parts of a split text are elements of the cue, in the order of the text (layout spec 5.10).
  • The names of the cues are unique in a component, also across two sequences. A cue-<name> that names no cue of the component is a compile error.
The places.
  • The first cue starts at its gap. Each other cue starts at the end of the cue before, plus its gap. With with, it starts at the start of that cue, plus its gap. A start below 0 is 0.
  • Element i of n starts at the start of its cue, plus an extra time. The extra time follows the table of Transition (component reference 4.15). It is i * stagger from the start and (n - 1 - i) * stagger from the end. From the center, it is floor(abs(2 * i - (n - 1)) / 2) * stagger.
  • The end of a cue is its start, plus ms, plus the largest extra time of its elements. A cue with no elements keeps its ms. The length of the sequence is the latest end of its cues.
  • When the count of the elements of a cue changes, the places change with it. A new element takes the current time of its sequence.
  • Before its start, an element of a cue shows the first stop of its keyframes. After its end, it shows the last stop (layout spec 5.10, ADR-0150).
The timeline of a sequence. A sequence has one of two timelines:
  • play: <Bool> runs the sequence in time. The value has the dependencies of a derived value. While it is true, the sequence runs to its end. While it is false, the sequence runs back to its start. A change during a run turns the sequence at its current time.
  • At the mount, a sequence with play true starts from its start, as a CSS animation that applies at the start does. With play false, it waits at its start.
  • timeline: "<name>" runs the sequence with the scroll. The name is a scroll timeline or a view timeline of the same file (layout spec 5.10). The optional range: "<range>" has the text of animate-range-, for example "contain". Without it, the sequence uses the whole timeline.
  • With a timeline, the start of the range is the time 0, and the end of the range is the length of the sequence. The range uses one range name at both ends, or offsets alone. So each cue is a part of one range. Another range is a compile error.
  • A sequence with both play and timeline, or with neither, is a compile error.
Other rules.
  • With the setting of reduced motion on, a sequence with play goes to its end or to its start at once. A motion value does the same with its target. A sequence with a timeline does not run, and each element shows its own style.
  • The runtime stops a sequence when its component leaves the tree.
  • Each feature of this section works on the web, on the native targets and in the interpreter.
How a sequence relates to the other forms:
Form
Task
transition
A change of the style of one element
Transition
An element that a block adds or removes. Its stagger is for a batch of elements that come in one frame
animate-<name>
The keyframes of one element on its own timeline, with animate-delay- (layout spec 5.10)
motion
One number that moves to a target (section 7.5)
sequence
An order of several animations on one timeline, with gaps, overlaps and a stagger
A row that knows its index uses delay-{i * 40} or animate-delay-{i * 40}. The stagger of a cue is for elements that have no index in the source, for example the parts of a split text.

7.6 Events

surf
event submit(text: String);fn send() { emit submit(draft);}
The parent listens with on:submit={handler}. An event without a listener does nothing. Event handlers can be async functions. The runtime cancels a running handler task when its component is destroyed.An event that a component sends while it leaves the tree, in on destroy or in a cleanup, does not reach the parent. The parent is in step 3 of a batch then (section 10.5), and its handlers cannot run.

7.7 Slots

A slot is a part of a component that the parent fills with markup.Declaration in the child:
surf
slot default;slot header?;slot row(bindable item: Todo);
Use in the child markup:
surf
<Column> @slot header #for item in items key item.id { @slot row(item) } @slot default</Column>
Fill in the parent:
surf
<TodoTable bind:items={todos}> #fill header { <Text style="text-20 font-600">Tasks</Text> } #fill row(todo) { <Toggle bind:checked={todo.done}>{todo.title}</Toggle> } <Text>Markup outside a fill goes into the default slot.</Text></TodoTable>
  • TodoTable declares bindable prop items: List<Todo>, and the parent binds it. A write in the fill then changes todos in the parent.
  • A slot name with ? is optional. An unfilled optional slot renders nothing.
  • A required slot without a fill is a compile error in the parent.
  • A slot can have parameters. The fill receives the values from the child.
  • A slot parameter with bindable (slot row(bindable item: Todo)) must receive a place from the child. In the fill, the parameter is then also a place, and a write to it changes the original value. Without bindable, a fill parameter is a constant.
  • The built-in List declares slot row(bindable item: T). When items is a place, each row can write to its item.

7.8 Traits and impl blocks

A trait names methods that a type can have. An impl block gives the methods to a type (ADR-0134). Traits have the form of Rust.
surf
// shapes.surfspub trait Shape { fn area(self) -> Float; fn describe(self) -> String = "a shape with the area {self.area()}";}pub type Circle = { r: Float }pub type Square = { side: Float }impl Shape for Circle { fn area(self) -> Float = 3.14 * self.r * self.r;}impl Shape for Square { fn area(self) -> Float = self.side * self.side;}impl Circle { pub fn new(r: Float) -> Circle = { r: r }; pub fn grow(mut self, k: Float) { self.r *= k; }}
Methods and associated items
  • A method has self as its first parameter. self is the value, and Self is its type. A call is c.area().
  • A method with mut self changes its value. Its call needs a place (section 5.2), for example shape.grow(2.0) on a state. The call is a statement of its own, as xs.push(1) is. A method without mut cannot write to self.
  • A function with no self is an associated function. A call names the type, for example Circle.new(2.0). For a trait with fn zero() -> Self, the calls are Int.zero(), and T.zero() in a generic function. A plain impl can have associated functions too, for example a constructor.
  • A method of a trait can have a default body. An impl can replace it.
  • A trait can have an associated type: type Item;. Each impl gives it a type: type Item = Int;. A signature names it Self.Item, and a generic function names it T.Item.
  • An impl of a trait must have each method of the trait that has no default body, with the same signature. It cannot have other items. Two impls of one trait for one type are a compile error.
Where an impl is valid
  • Traits and impl blocks are valid only in a module file (section 7.1). A method is a function of a module file, so it cannot read the reactive sources of a component (section 7.3). It can read Locale, as each function can (section 10.2).
  • An impl of a trait is valid only in the module of the trait or in the module of the type. A plain impl is valid only in the module of the type. This is the orphan rule of Rust. Rust applies it to a crate, and Surf applies it to a module.
  • A module exports a trait that starts with pub. A method of a plain impl is visible outside its module only when it starts with pub. A method of an impl of a trait has the visibility of the trait.
  • An impl can have type parameters: impl<T: Text> Text for Page<T> { ... } (section 7.9).
The import of a traitA file calls a method of a trait only when it imports the trait, as in Rust (ADR-0134). A plain impl needs no import.
surf
// App.surfimport { Circle, Shape } from "./shapes.surfs";derived area = Circle.new(2.0).area();
  • Circle.new is a function of a plain impl, so it needs no import. area is a method of Shape, so the file imports Shape.
  • The file that declares the trait needs no import.
  • An associated function of a trait needs the import too, for example Int.zero() for a trait Zero of another file.
  • A call through a bound (T: Shape) or on a dyn Shape value needs no import, as in Rust. The bound or the type names the trait.
  • The built-in traits need no import (section 7.2).
  • A call without the import is a compile error. The message names the trait, and its help gives the import with the path from this file:
  • text
    error: `area` is a method of the trait `Shape`, and this file does not import the trait = help: add `import { Shape } from "./shapes.surfs";` to this file
  • For an associated function, the message says "function" in place of "method".
Built-in traits
Trait
Method
Gives
Built-in types with an impl
Equatable
equals(self, other: Self) -> Bool
== and !=
each type that == takes (section 5.1)
Comparable
compare(self, other: Self) -> Ordering
<, <=, >, >=, sort and the key of sortBy
Int, Int64, Float and String
Hashable
hash(self) -> Int
the key of a Map (section 4.5)
Int, String and Bool
Text
text(self) -> String
{x} in a string and in the markup (section 5.4)
String, Int, Float and Bool
Number
none
the operators of numbers
Int and Float
Token
none
the value of Theme.setToken (ADR-0082, ADR-0130)
Color and Float
  • Equatable and Hashable are automatic for records and enums. The automatic == compares each field (section 5.1). A record or an enum has the automatic Hashable when each of its fields has Hashable, as #[derive(Hash)] in Rust.
  • A record or an enum with a field whose type holds a dyn type gets no automatic Equatable and no automatic Hashable (ADR-0135). Examples of such fields are shape: dyn Shape and shapes: List<dyn Shape>. A program writes an impl when it needs one.
  • An impl of Equatable replaces the automatic comparison. A type with an impl of Equatable must also have an impl of Hashable to be a map key. The compiler checks this rule.
  • Comparable and Text need an impl. A record or an enum has no automatic impl of them.
  • Comparable includes Equatable (ADR-0135). A type with an impl of Comparable must have Equatable, from an impl or from the automatic ==. A bound T: Comparable gives == and != too.
  • <, <=, > and >= use the result of compare. Ordering is a built-in enum with the cases less, equal and greater.
  • A bound T: Number gives +, -, *, /, % and prefix - on values of T, and the traits Comparable (with Equatable) and Text.
  • A program cannot write an impl of Number or of Token.
Operator traits
Trait
Operator
Method
Add
a + b
add(self, other: Rhs) -> Self.Output
Sub
a - b
sub(self, other: Rhs) -> Self.Output
Mul
a * b
mul(self, other: Rhs) -> Self.Output
Div
a / b
div(self, other: Rhs) -> Self.Output
Rem
a % b
rem(self, other: Rhs) -> Self.Output
Neg
-a
neg(self) -> Self.Output
  • Each operator trait has the form of its Rust trait. Rhs is the type of the right side. It is Self, unless the impl names another type: impl Mul<Float> for Vector. The associated type Output is the type of the result.
  • Each built-in type with an operator in section 6.2 has an impl of the trait of that operator, for example Add for Int and for String.
  • a += b is a = a + b, and the result must have the type of the place. The other assignment operators of section 5.2 follow the same rule.
surf
pub type Vector = { x: Float, y: Float }impl Add for Vector { type Output = Vector; fn add(self, other: Vector) -> Vector = { x: self.x + other.x, y: self.y + other.y };}impl Mul<Float> for Vector { type Output = Vector; fn mul(self, k: Float) -> Vector = { x: self.x * k, y: self.y * k };}impl Text for Vector { fn text(self) -> String = "({self.x}, {self.y})";}
Each feature of this section works on the web, on the native targets and in the interpreter.A method cannot be an async fn.

7.9 Type parameters with bounds, and trait objects

Bounds
surf
pub fn largest<T: Comparable>(xs: List<T>) -> T { let best = xs[0]; for x in xs { if x > best { best = x; } } return best;}pub fn label(value: impl Text) -> String = "Value: {value}";pub type Page<T> = { items: List<T>, next: String? }
  • A function, a record, an enum and a component can have type parameters with bounds (ADR-0135). A bound names one or more traits: <T: Comparable + Text>.
  • The type of a type parameter must have an impl of each trait of its bound. Another type is a compile error at the call or at the use.
  • x: impl Token is the short form of a type parameter with a bound. It is valid for a parameter and for a return type. Each impl in a parameter is a type parameter of its own.
  • Use the long form when two parameters, or a parameter and the return type, have the same type: fn larger<T: Comparable>(a: T, b: T) -> T.
  • A return type impl Shape is one type that the function chooses. Each return of the body must give that type. The caller sees only the methods of the trait.
  • An impl return type has only the traits of its bound. Each impl return type is a type of its own, also when two functions have the same bound. A generic function with an impl return type gives one type for each list of types of its calls. A method of a trait cannot return an impl type.
  • Dispatch is static. The compiler makes one copy of a generic function for each list of types that the app uses (ADR-0080). So an app pays only for what it uses.
  • A call through a bound needs no import, because the bound names the trait (section 7.8). A file that writes the bound T: Shape names the trait, so it declares or imports Shape.
  • In a generic function, T.zero() calls an associated function of a trait of the bound of T, and T.Item names an associated type of such a trait (section 7.8). T: Mul<Float> gives a * k with k: Float, and the result has the type T.Output.
Impls and methods with type parameters
surf
pub type Page<T> = { items: List<T> }impl<T: Text> Text for Page<T> { fn text(self) -> String = "{self.items.count()} items";}impl<T> Page<T> { pub fn first(self) -> T? = self.items.get(0); pub fn map<U>(self, f: (T) -> U) -> Page<U> = { items: self.items.map(f) };}
  • An impl with type parameters is for a generic record or enum. Each type parameter of the impl is a type argument of the type, once: impl<T> Page<T>.
  • A type has the methods of such an impl when its type arguments have the traits of the bounds of the impl. Page<User> has the method text only when User has Text.
  • Two impls of one trait for one generic type are a compile error, also with different bounds.
  • A method can have type parameters of its own, for example U of map. A method of a trait with type parameters has the same type parameters and bounds in each impl, in their order. Their names can be different.
  • The compiler makes one copy of each method of such an impl for each list of types that the app uses.
  • A call of an associated function of such an impl names the generic type without its types: Page.empty(). The expected type of the result and the values of the call give the types, as for a generic function (section 7.3): const p: Page<Int> = Page.empty();.
Generic components
surf
// Picker.surfcomponent Picker<T: Text>;prop items: List<T>;bindable prop selected: T? = none;---<Column style="gap-1"> #for item in items { <Button on:press={selected = item}>{item}</Button> }</Column>
surf
<Picker items={users} bind:selected={chosen} /><Picker<User> items={users} />
  • component Name<...>; declares the type parameters of a component. It is valid only in the script of a component file, and its name must be the name of the file. A component without type parameters has no such declaration.
  • The root component, App.surf or a page in routes, cannot have type parameters, because no parent gives their types.
  • Each declaration of the script can use the type parameters, for example a prop, a state or a function.
  • The checker infers the types of each use from its props. A use can also name them after the name of the component: <Picker<User> items={users} />. The end tag has no types: </Picker>.
  • A prop gives a type when its value has a type without the help of the prop. A lambda without the types of its parameters and an empty list give no type. Then the use names the types.
  • The types of a use have the traits of the bounds. The props, the events and the slots of the use have these types.
  • The compiler makes one copy of a generic component for each list of types that the app uses, as for a generic function. The copies have the style rules of the component once.
Trait objects
surf
import { Shape, Circle, Square } from "./shapes.surfs";state shapes: List<dyn Shape> = [Circle.new(1.0), Square { side: 2.0 }];derived total = shapes.map(s => s.area()).sum();
  • A type can be dyn Trait directly, for example List<dyn Shape> or prop shape: dyn Shape. There is no Box. The runtime holds the value, and a table of functions finds each method at run time (ADR-0135).
  • A value of a type with an impl of the trait becomes a dyn value where the expected type is dyn Trait.
  • A dyn value has the value semantics of section 5.1. An assignment gives the receiver its own copy.
  • A dyn value has only the methods of its trait.
  • == and != on a dyn value are a compile error, because Equatable cannot be dyn (ADR-0135). A dyn type cannot be the key type of a Map, because a key needs ==. A record or an enum with a dyn field gets no automatic Equatable and no automatic Hashable (section 7.8).
  • A trait can be dyn only when it follows the object rules of Rust:
  • Each function of the trait has self. A trait with an associated function cannot be dyn.
  • No method returns Self, and no method has a parameter of the type Self. So Equatable and Comparable cannot be dyn.
  • A dyn type names each associated type of its trait, for example dyn Source<Item = Int>.
  • A dyn type of an operator trait names the type of the right side, because Self has no meaning there: dyn Mul<Float, Output = Vector>. This is error E0393 of Rust.
  • Number and Token cannot be dyn. Number includes Equatable. A dyn type of Token is not in version 1.
  • A value of a dyn type has the trait of its type, as dyn Shape: Shape in Rust. So a generic function fn total<T: Shape>(xs: List<T>) takes a List<dyn Shape>.
  • A value becomes a dyn value with no import of the trait, as in Rust. A List<Circle> does not become a List<dyn Shape>, and a Circle? does not become a dyn Shape?. A value of Circle becomes a dyn Shape?.
  • A method with mut self of a dyn value changes the value. The dyn value keeps its table.
  • A type at the boundary of an extern module cannot be dyn or impl (section 13.1, ADR-0133).
Each feature of this section works on the web, on the native targets and in the interpreter.

8. Async code

  • async fn load() -> Result<Data, Error> returns a Task<Result<Data, Error>>. A call starts the task at once. The task runs until its first await before the call returns.
  • await task waits for the task and gives its value. await is valid in an async fn and in an event handler.
  • In version 0, await must be at the start of a statement value: const x = await load(), let x = await load(), x = await load(), await load() and return await load(). Other positions are a compile error, for example 1 + await load().
  • spawn task starts a task and does not wait for it. A call of an async fn as a statement does the same.
  • A task can start only in a component: in a component function, an effect, on mount, on destroy or an event handler. In a module function and in an initial value, a call of an async fn must be a direct await in an async fn.
  • sleep(ms: Int) is a built-in async fn. Its task ends after ms milliseconds.
  • interval(ms: Int) is a built-in async fn. Its task ends at the next time that is a multiple of ms on the clock of the app. This clock counts from the start of the app. A loop with await interval(ms) does not drift: the time of its work does not move the next end. The first end can come before ms. For both functions, a time of 0 or less ends the task at once.
surf
state seconds = 0;async fn tick() { while true { await interval(1000); seconds += 1; }}on mount { tick();}
The runtime cancels the task of tick when the component leaves the tree, so the loop needs no cleanup. ADR-0026 gives the reasons for this form in place of a Timer module with callbacks.
  • An event handler that contains await runs as a task.
  • All code runs on the UI thread. An await lets other events run until the task continues.
  • Each await ends the current batch (section 10.5). The UI shows the changes before the await. The end of a task also ends a batch.
  • A task belongs to the component that started it. The runtime cancels the task when the component is destroyed. A cancelled task does not continue after its current await.
  • Two tasks are equal only if they are the same task.

9. Markup

9.1 Elements

text
<Name attribute=value>children</Name><Name attribute=value />
  • A name must be a built-in component or an imported component.
  • A lowercase name such as <div> is a compile error. There is no raw HTML, because raw HTML has no meaning on native targets.
  • A component file can have more than one root element. The parent lays out each root as a separate child.
  • The start tag of a generic component can name its types: <Picker<User> items={users} /> (section 7.9, ADR-0135).

9.2 Attributes

Form
Meaning
name="text"
String value. {...} inside it inserts values.
name={expression}
Value of any type
name
Short form of name={true}. Valid only for Bool props.
{name}
Short form of name={name}
on:event={handler}
Event listener: a function name, statements, or a lambda whose body is statements
bind:prop={place}
Two-way binding. prop must be a bindable prop. The value must be a place.
style="p-2 gap-1"
Utilities: the styles of the element (section 12)
{...value}
One attribute for each field of a record. An attribute on the element wins (ADR-0083).
testid="name"
A name for tests. It has no effect on the app. Valid only on built-in components.
  • Two attributes with the same name on one element are a compile error.
  • On an instance of a user component, the style of the instance applies to the root elements of the component, with its computed values and its conditions. This is the same as on an element (layout spec 12).
  • On a built-in component, on:, bind: and the attributes follow the component reference.
  • The value of an on: attribute is one of these:
  • a function name: on:submit={add}. The function gets the value of the event, if it takes a parameter.
  • statements, which run at each event: on:press={count += 1}, on:press={remove(todo.id)}, on:press={save(); close()}.
  • a lambda whose body is statements, to use the value of the event: on:change={done => setDone(todo.id, done)}, on:keydown={key => last = key}.

9.3 Text

  • Literal text and {expression} are valid only inside a component that accepts text. The built-in components that accept text are Text, Span, Button, Link, Toggle and Option. A user component accepts text in the fill of each of its slots that is inside one of these. For the default slot, the fill is the children of the instance.
  • Text in any other place is a compile error.
  • Each run of white space (spaces, tabs, line ends) becomes one space. White space at the start and at the end of a text block is removed.
  • Text has no HTML entities. &amp; stays the five letters &amp;. Write & for an ampersand. Write {"<"} for a <, and {"\{"} for a {, because these characters start a tag and an expression.
  • A Text and its parts are one text (ADR-0111):
  • A Span is valid only in a Text or in a Span. A Link in a Text is a part of the text. Other elements in a Text are a compile error. The fill of a slot in a Text takes a Span and a Link too.
  • The content of a Text is one text block. A Span and a Link do not end it, so a space next to them stays: Run <Span>surf dev</Span>, then has a space before the Span and after the comma. A run of white space across the edge of a Span is one space.
  • A block or a tag ends a text block, and the body of a block is a text block of its own. So a space next to a block goes: #for t in tokens { <Span>{t.text}</Span> } puts no space between the tokens. Write {" "} for a space there.

9.4 Interpolation

{expression} in text follows section 5.4. The value updates when its dependencies change.

9.5 Blocks and tags

The markup has two kinds of control words:
Sigil
Kind
Words
#
A block: a head, then a body in { ... }. Some blocks continue after their }.
#if (else if, else), #for (empty), #match, #await (then, catch), #fill, #try (catch)
@
A tag: one line, without a body.
@const, @slot
  • The head of a block uses the syntax of the script. #if is the if statement, #for is the for loop, and #match is the match expression with markup in its arms.
  • A # or an @ is a control word only when one of the words of the table follows it. It must also be at the start of a line, or directly after a tag or a }. Everywhere else it is text: <Text>#1 of {n}</Text>.
  • A } in markup ends the body of a block. For a } in text, write {"}"}.
Condition
surf
#if count > 10 { ...} else if count > 5 { ...} else { ...}
List
surf
#for todo, index in todos key todo.id { ...} empty { <Text>No tasks.</Text>}
  • The words and the order are the words and the order of the for loop of the script (section 6.1): #for item in list { ... }, #for item, index in list { ... }.
  • The value must be a List<T> or an Int range.
  • key <expression> is optional. A key must be an Int or a String. The runtime uses the key to keep the identity of an item when the list changes.
  • Two equal keys in one list are a runtime error.
  • Without a key, the position in the list is the identity. surf lint warns when a list of records has no key (ADR-0070).
  • If the list is a place, todo is also a place, and a write to todo changes the element in the list.
  • The list can also be a computed value, for example #for todo in todos.filter(t => !t.done) key todo.id { ... }. Then todo is a constant: a write to it or bind: with it is a compile error. To change the element, write to the original list, for example through a function that finds the element by its key.
Match
surf
#match shape { .circle(r) => <Text>Circle, radius {r}</Text> .rect(w, h) => { <Text>Rectangle</Text> <Text>{w} by {h}</Text> }}
  • Each arm is pattern => markup. The markup is one element, block or tag, or nodes in { ... }.
  • The arms must cover every value (section 6.3).
Await
surf
#await profile { <Text>Loading</Text>} then user { <Text>{user.name}</Text>} catch error { <Text style="text-$error">{error.message}</Text>}
  • The value must be a Task<T> or a Task<T>?. While the value is none, the block shows nothing.
  • If T is Result<V, E>, then receives the ok value and catch receives the err value. Otherwise catch is not valid.
  • A new task in the value shows the first branch again, until the new task ends.
Slots and local constants
  • #fill name(parameters) { ... } fills a slot of a child (section 7.7). It is valid only as a direct child of a component element.
  • @slot name(arguments) renders a slot in the component that declares it (section 7.7).
  • @const x = expression declares a constant for the rest of the enclosing block. The expression ends at the end of the line.
surf
#for item, i in items key item.id { @const label = "{i}: {item.name}" <Button alt={label}>{label}</Button>}
Error boundary
surf
#try { <Chart data={points} />} catch message { <Text>The chart failed: {message}</Text>}
  • The catch body shows when the mount or an update of the #try body has a runtime error (section 15.2). This includes the child components of the body.
  • catch is necessary. Its name is optional, and it is a String that does not change.
  • The #try body stays gone after the error, and the rest of the app goes on.
  • An error in an event handler, in an effect or in a task goes to on error.
  • An error in the catch body goes to on error.

9.6 Built-in components (version 0)

Component
Function
Accepts text
Web element
Row
Horizontal flex container
no
<div>
Column
Vertical flex container
no
<div>
Stack
Children on top of each other
no
<div>
Grid
Grid container
no
<div>
Scroll
Scrollable area
no
<div>
List
Virtualized list, renders only the rows near its visible area (ADR-0027)
no
<div role="list">
Text
Text
yes
<span>
Span
A part of a Text with a style of its own (ADR-0111)
yes
<span>
Link
A link. In a Text, a part of the text
yes
<a>
Image
Image
no
<img>
Video
A video with its sound, captions and controls (ADR-0156)
no: it takes Source and Track elements
<video>
Audio
A sound, with controls or with no box (ADR-0156)
no: it takes Source and Track elements
<audio>
Source
One file of a Video or an Audio, with its format
no
<source>
Track
A WebVTT file of captions or subtitles (ADR-0157)
no
<track>
Icon
Icon from an SVG file (ADR-0030)
no
<svg> with <path> elements
Button
Button
yes
<button>
Input
One-line text input. kind="date" or kind="number" gives the type of the element on the web. A native field stays a text field.
no
<input>
TextArea
Multi-line text input
no
<textarea>
Toggle
Switch or check box
yes
<label> with <input type="checkbox"> (layout spec, section 4)
Slider
Value slider
no
<input type="range">
Select
Choice from a list
no: it takes Option elements
a <button> and a list in a <div>, or with system a <select> in a <div>
OptionList
The box of the open list of a Select, for its styles
no: it takes Option elements
<div>
Option
One choice of a Select
yes
<option>
Overlay
An element above all others, next to its parent (ADR-0032)
no
an element with popover
Transition
Animates the elements that its blocks add and remove (ADR-0036)
no
no element of its own
Drawing
A box with its own coordinates for shapes (ADR-0029)
shapes and Group
<svg viewBox>
Line, Polyline, Polygon, Rect, Circle, Ellipse, Path, Group
Shapes in a Drawing
Group: shapes
the SVG element of the name
Canvas
Immediate-mode drawing
no
<canvas>
PlatformView
Native view (native targets only)
no
not available
The component reference gives the attributes, the bindings and the events of each component. An attribute or an event that is not in the reference is a compile error.Not in version 0:
  • Canvas and PlatformView. Each of them is a compile error (component reference, section 4.16).

9.7 The keyboard focus

  • Focus.to(id) gives the keyboard focus to the element with this id (ADR-0102). It takes effect at the end of the batch, so the element can come into the tree in the same batch. A row of a List that shows again can come in the next frames, so the web target tries again in the next three frames (ADR-0110). An id that no element has does nothing.
  • Focus.newId() gives a String that no other call gives in this run of the app. A component uses it for id, controls and labelledBy: state uid = Focus.newId();.
  • On the native targets, only a control can get the focus.

10. Reactivity

10.1 Sources and consumers

  • Reactive sources: prop, bindable prop, state, derived and motion declarations.
  • Reactive consumers: markup expressions, derived expressions and effect bodies.

10.2 Dependencies

The compiler finds the dependencies of each consumer at compile time. The dependencies of a consumer are:
  • the sources that the consumer reads directly
  • the sources that the component functions read, for each component function that the consumer calls, transitively
Functions from module files cannot read the sources of a component (section 7.3), so they add no dependencies on them. This is also true for the methods of an impl (section 7.8) and for a call through a dyn value (section 7.9).Locale is the exception (section 14, ADR-0132). Each function can read it, also a function of a module file and a method. The checker follows each call, transitively. A consumer that calls a function that reads Locale depends on Locale, so it updates after Locale.set. A call through a dyn value depends on Locale when an impl of the method in the app reads it.A call of a function value depends on Locale when a function value of the same type in the app reads Locale (ADR-0132). This applies to each function value: a prop (section 10.3), a state, a field, an item of a list or a map, and a parameter. For example, with prop format: (Int) -> String, the call format(count) depends on Locale when a lambda or a module function of the type (Int) -> String reads it. This is the conservative rule of a call through a dyn value. So the child updates after Locale.set.A write to a place is not a read of the place. total = v and log.push(v) read v and the indexes in the place, but not total or log. An operator such as += reads its target.The analysis is conservative. A read inside a branch counts as a dependency, also when the branch does not run. The result can be more updates than necessary, but never a missed update, and the result is the same on every target.

10.3 Lambdas as props

A lambda that reads reactive sources is itself a consumer in the parent. When one of its dependencies changes, the parent sends the lambda again, and the child sees a changed prop. This keeps the dependency analysis static across component boundaries.A function prop has a function type: prop format: (Int) -> String. Its value is a function value (section 6.4), for example:
  • a lambda: format={(n) => "{n} {unit}"},
  • a module function, by its name: format={formatWeight},
  • a function prop of the parent, by its name.
The child calls it like a function: {format(count)}. These rules apply:
  • The lambda keeps copies of the values that it reads when the parent makes it. The parent makes it again when one of these values changes.
  • The lambda cannot call a function of the parent component, because the child runs the lambda. Use a module function.
  • An async fn cannot be a function prop.
  • The default value of a function prop cannot read the sources of its component.
  • A function prop cannot be bindable. Its type cannot take or give a task.
  • Two function values are equal only when they are the same value. So a new lambda from the parent is always a change.

10.4 Writes

  • A write to a source, or to a place inside a source, marks the source as changed (with the exception in section 5.3).
  • Writes are valid in functions, event handlers, effects, on mount and on destroy.
  • A write in a derived expression or in a markup expression is a compile error.
  • Locale.set is a write (section 14, ADR-0132). In a derived expression and in a markup expression, it follows the rules of a write of a state. So it is a compile error there. An on: attribute can call it, because its value is statements.

10.5 Batches

Writes do not change the UI at once. The runtime collects writes into a batch. A batch ends when the current event handler, effect, on mount block or task step (up to the next await) returns.At the end of a batch, the runtime does these steps in this order:
  • It sends changed props from parents to children.
  • It marks the affected derived values as stale. A stale value is calculated again when something reads it.
  • It updates the markup of each affected component, parent before child.
  • It lays out and paints the changes in the next frame.
  • It runs the affected effects, in tree order (parent before child), and in declaration order inside one component.
Writes from step 5 start a new batch. After 100 batches in a row from effects, without an event from outside, the runtime stops with a runtime error ("the effects started 100 batches in a row").

10.6 Guarantees

  • No partial states: each consumer in a batch sees the values after all writes of the batch.
  • Deterministic order: the order of updates and effects depends only on the source code and the events, not on the target.

11. Component lifecycle

  • The parent calculates the props.
  • The const and state initializers run in declaration order.
  • The runtime creates the markup. Child components go through the same steps.
  • The first frame shows the component.
  • The effects run for the first time, then on mount runs. Children run these steps before their parent.
  • Each batch updates the component (section 10.5).
  • When the component leaves the tree, the runtime cancels its tasks, runs the effect cleanups, then runs on destroy. Children run these steps before their parent.

12. Styles (summary)

The styles of an element are utilities in its style attribute. The Surface Layout Spec v0, section 12, lists every utility. This section gives a summary.
surf
<Column style="p-6 gap-3 bg-$surface rounded max-600:p-4"> <Text style="text-20 font-600 text-$text-strong">Title</Text> <Button style="px-4 h-9 bg-$accent text-$on-accent hover:opacity-0.85 active:scale-0.97 {saved}:bg-$success" > Save </Button> <Column style="h-2 bg-$accent w-{progress}%" /></Column>
  • Each utility sets one style property of the layout spec, or one part of it: p-2 sets all sides of padding, pt-2 the top side.
  • For spacing and sizes, a number is a number of steps (ADR-0098). One step is 0.25rem, so p-2 is 8 logical px at the default root size. p-2px is exact, and a value can have a unit: w-50vw, p-1rem (ADR-0067). Other utilities keep exact numbers: border-2, text-14, rounded-8.
  • $name is a theme token (layout spec, section 2.1). A token always has $. An unknown token is a compile error. A bare name is a constant: gap-inset, text-xs.
  • A value with spaces has _ for each space: shadow-0_2_8_rgb(0_0_0_/_0.15).
  • Variants come before a utility. A variant is a state (hover:, active:, focus-visible:, disabled:) or a theme (dark:, light:). It can also be a platform (ios:, section 14), a named breakpoint (md:, max-md:) or a window width (min-600:, max-600:).
  • A computed value goes in braces after the prefix: w-{width}px. A computed transform runs no layout: translate-x-{x}px, translate-y-{y}px, scale-{s}, rotate-{r}.
  • {condition}: is a variant (ADR-0096): {saved}:bg-$success. The value must be a Bool. It can come with other variants: {!open}:max-md:hidden. A condition wins over the same utility with no condition.
  • (...) groups utilities under one variant: {saved}:(bg-$success text-$on-accent), hover:(underline opacity-0.8).
  • {value}:{ case: utilities, } is a case list (ADR-0096). The value must be an enum. The compiler checks each utility.
  • The list must name each case. A case with fields is a compile error. Write case:, for a case with no style.
  • A String value uses one condition for each value: {status == "open"}:text-$success.
  • The utilities of one case cannot set the same part of a property twice. Two cases can set the same property, because only one case is on.
  • The utilities are fixed text. A computed value goes outside the list.
  • @name uses a preset from style-presets.toml (ADR-0091, layout spec, section 2.2). A utility on the element wins over a preset.
  • A word that is not a utility is a compile error. Two utilities of one element can set the same part of a property with the same variants. This is a compile error, so the order of the words has no effect.
  • The style of a user component applies to the root elements of that component.
  • A style property as an attribute, for example gap={12}, is a compile error. The help text of the error gives the utility.
The layout spec, section 12.3, gives the precedence of the declarations.

13. Extern modules

An extern module declares functions that each target implements in its own language.
surf
// haptics.surfspub enum Strength { light, medium, heavy }pub extern module Haptics { fn impact(strength: Strength); async fn isAvailable() -> Bool; fallback { fn impact(strength: Strength) {} async fn isAvailable() -> Bool = false; }}

13.1 Types at the boundary

Parameters and return values must use only these types:
  • Int, Float, Bool, String, Void
  • List<T>, Map<K, V>, T?, Result<T, E>
  • records and enums whose fields use only these types
Function types are not valid at the boundary (ADR-0133). Events (section 13.3) and async fn calls (section 13.4) do the work of a callback.A function or an event of an extern module has no type parameters. A type at the boundary cannot be a dyn type or an impl type (ADR-0133).

13.2 Implementation files

Implementation files are in the same folder as the module file. The name is <module file>.<target>.<extension>:
File
Used for
haptics.ios.swift, haptics.macos.swift
One Apple target
haptics.apple.swift
Apple targets without a more specific file
haptics.android.kt
Android
haptics.macos.rs, haptics.windows.rs, haptics.linux.rs
One desktop target
haptics.native.rs
Native targets without a more specific file
haptics.web.js
Web (Phase 1). haptics.web.ts is a decision for later.
  • The most specific file wins: target, then group, then native.
  • If a target has no implementation file, the target uses the fallback block. If there is no fallback block either, the build for that target fails with a compile error.
  • surf extern stubs [folder] writes a stub of each implementation file that does not exist yet: haptics.native.rs and haptics.web.js. A stub has each function of the module with its types, and a body that panics or throws. A stub replaces the fallback block on its targets. The command also writes haptics.web.d.ts, the TypeScript declarations of the web implementation file, as surf build does.
  • The desktop targets and a SurfWave server accept Rust implementation files. A String parameter is &String in Rust, and surf extern stubs writes the exact types. The file can use the standard library and the runtime, and no other crate. Swift and Kotlin files are for the mobile targets.
  • The fallback block must have each function of the module, with the same signature, and no other function.
An app that embeds in a host shares its process with other apps (ADR-0056). surf_core::task::current_view() gives the number of the app that calls a function. emit.to(view) sends an event to that app only. emit alone sends to all apps.A web implementation file is an ES module. It exports one function for each function of the module, with the same name. The values cross the boundary as JavaScript values:
Surf type
JavaScript value
Int, Float
number
Bool, String
boolean, string
List<T>, Map<K, V>
array, Map
T?
the value, or null
Result<T, E>
{ $: "ok", value } or { $: "err", value }
record
object with the fields
enum
the case name for a case without fields, else { $: "case", ...fields }
An async fn returns a promise.

13.3 Events

An extern module can send events to the app (ADR-0031). It declares each event, and a component listens with a declaration:
surf
// plugin.surfspub extern module Plugin { fn setParam(id: Int, value: Float); event report(params: List<Float>, peak: Float); fallback { fn setParam(id: Int, value: Float) {} }}
surf
import { Plugin } from "./plugin.surfs";state level = 0.0;on Plugin.report(params, peak) { level = peak;}
  • The values of an event follow the rules of section 13.1. A listener has one name for each value, and the names take the types of the event.
  • A listener can write the state of its component, and it runs as one batch. It lives as long as the instance of the component. All mounted instances with a listener get each event, in the order in which they mounted.
  • An event waits until the current batch ends. Events keep their order, and no event is dropped or merged. An event without a listener is lost.
  • An implementation file of a module with events has one more function, start, which takes emit. The runtime calls it once, when the first listener mounts. emit has a method for each event: emit.state(params, peak). A web file exports function start(emit). A Rust file has pub fn start(emit: Emit). Emit is Copy and Send, so any thread can send, and the send wakes the event loop.
  • start is not a valid name for a function of a module with events.
  • A Rust file names the items of the module by their Surf names. A name that Rust or the generated code uses gets __ at the end: a Rust keyword that cannot be a raw identifier (self), a name of the Rust prelude (Vec, Some), a name of surf_core::prelude (Event, Tree) and the names Emit, imp, STYLESHEET and surf_bridge. Another keyword is a raw identifier (r#type). An event to is the method to__ of Emit, because to sends to one app (ADR-0115).
  • Without an implementation file, the module uses its fallback block and no event comes. A fallback block cannot send events in version 0.
  • The interpreter (surf dev --target desktop) delivers no events in version 0.
A Rust implementation file has the items of a Rust module. The compiler includes the file in the module of the extern module. So the file can name the types of the module file and of its imports. The file has a pub fn for each function of the module, with the same name, and a pub async fn for each async fn. The file cannot have inner attributes (#![...] or //!). The values cross the boundary as Rust values:
Surf type
Rust value
Int, Float, Bool
i64, f64, bool
String
String
List<T>, Map<K, V>
Vec<T>, surf_core::Map<K, V>
T?
Option<T>
Result<T, E>
Result<T, E>
record
a struct with the fields
enum
an enum with the cases
A parameter of a type other than Int, Float and Bool is a shared reference, for example &String or &Vec<Todo>. The native executor has no wakers: a future of a Rust implementation calls surf_core::task::progress() when it becomes ready. surf_core::data::dir() gives the data folder of the app, where an implementation can keep files.

13.4 Calls

  • A synchronous call runs on the UI thread. The implementation must return quickly.
  • An async fn call can run on any thread. The result comes back on the UI thread and starts a new batch.
  • A call names the module: Haptics.impact(.light). A call of an async fn starts a task, as other async fn calls do (section 8).
  • The compiler generates direct bindings. These are a Rust call for Rust, the C ABI for Swift, JNI for Kotlin, and an ES module import for the web. There is no message serialization.

14. Platform information

  • The platform names are macos, windows, linux, android, ios and web. The groups are apple (macos, ios), mobile (ios, android), desktop (macos, windows, linux) and native (all targets except web). A platform variant of a utility (ios:p-8, section 12) takes these names.
  • Platform.os has the type enum Os { macos, windows, linux, android, ios, web }.
  • Platform.os is a compile-time constant. if Platform.os == .ios { ... } keeps the block only in iOS builds.
  • The compiler type-checks code for all targets, also code that a target removes.
  • Window.width and Window.height are the size of the window, as Float values in logical px (ADR-0106). A read has the dependencies of a state: a derived value, an effect and the markup update after a resize.
  • Window is valid only in a component, and a program cannot write it. A function in a .surfs file takes the size as a parameter. A declaration or an import with the name Window comes first.
  • A server has no window, so both values are 0 there.
LocaleThe module Locale formats values for a person in the locale of the app (ADR-0132). A locale is a language with an optional region, as a BCP 47 tag, for example en, de or pt-BR.
surf
state count = 3;state price = 1234.5;derived total = Locale.number(price, style: .currency(code: "EUR"));derived files = match Locale.plural(count) { .one => "{count} file", _ => "{count} files",};---<Column style="gap-2"> <Text>{total}</Text> <Text>{files}</Text> <Button on:press={Locale.set("de")}>Deutsch</Button></Column>
  • surf.toml lists the locales of the project, for example locales = ["en", "de"]. At the start, the app takes the locale of the system that matches the list best, else the first item of the list. The rule of the match follows below.
  • If a project uses Locale and its surf.toml has no key locales, the compiler gives an error. An empty list, locales = [], gives the same error (ADR-0132). The message gives an example line, for example locales = ["en"].
  • The MCP server and the compiler in the browser give this error too, because they check source text with no surf.toml (ADR-0132). A sample that uses Locale needs a project with a surf.toml.
  • Locale.set(tag) changes the locale while the app runs, and each format follows. A tag that fits no item of the list gives the first item.
  • Locale.set is a write (section 10.4, ADR-0132). It follows each rule of a write of a state, in each place. So a derived value and a markup expression cannot call it.
  • Locale.set does not save the locale. At the next start, the app takes the locale of the system again. This is the rule of Theme.setToken (ADR-0082). An app can save the choice with Storage.
  • Locale.current is the tag of the current locale, as a String. It is always an item of the list.
  • A read of Locale.current and a call of a function of Locale have the dependencies of a state, as a read of Window.width has. A derived value, an effect and the markup update after Locale.set.
  • Locale is valid in each function, also in a function of a .surfs file and in a method. For example, a module file can have fn due(t: Todo) -> String = Locale.date(t.due);. The checker follows each call (section 10.2). So a component that calls a function that reads Locale updates after Locale.set.
  • A module constant cannot read Locale, also through a call (section 7.3, ADR-0132). This is a compile error, and the message says to use a function. An instance constant of a component cannot read Locale either, and its message says to use a derived value. A call of a function value reads Locale when a function value of its type reads it (section 10.2).
  • Only Locale.set changes the locale. A declaration or an import with the name Locale comes first.
  • On a SurfWave server, each request takes the best match of its Accept-Language header in the list, else the first item of the list. Locale.current is the locale of the request. The page hydrates with the same locale (SurfWave, section 5).
surf
// labels.surfsconst PRICE = Locale.number(9.99); // compile error: a module constant reads Localepub fn price() -> String = Locale.number(9.99); // a function reads Locale at each call
The match. The app uses the lookup of RFC 4647 (ADR-0132). It takes the wanted tags in their order. For each wanted tag, it tries the tag, and then the tag without its last part, until a try fits an item of the list. So de-CH fits de, and zh-Hant-TW tries zh-Hant and then zh. A part of one letter, for example x in de-x-private, goes away together with the part after it. The lookup never adds a part, so de does not fit de-CH. A try fits an item when the two tags are equal apart from the case of the letters. BCP 47 gives the case no meaning. The first fit gives the locale. These are the wanted tags:
  • At the start of an app, the wanted tags are the languages of the system, in the order of the system. On the web, these are the languages of the browser.
  • On a SurfWave server, the wanted tags are the tags of the Accept-Language header of the request, in the order of their weights. Two tags with the same weight keep their order in the header. A tag with the weight 0 is not a wanted tag, because RFC 9110 gives it the meaning "not acceptable".
  • After Locale.set in the browser, each data request, each action and each remote call has the header x-wave-locale with the tag of Locale.current. The server uses this tag as the one wanted tag, in place of Accept-Language (SurfWave, section 5).
  • Locale.set(tag) has one wanted tag.
If no try fits, the app takes the first item of the list. Before 2026-11-04 the rule was exact tags only. The project owner changed it, because macOS often gives tags with a region, for example de-DE, and a German Mac then got English from the list ["en", "de"].
Function
Result
Rule
Locale.number(x, style: NumberStyle = .decimal)
String
x is an Int or a Float (impl Number). The text has the decimal separator and the grouping of the locale
Locale.date(ms, style: DateStyle = .medium)
String
The date of ms in the time zone of the machine, as Time.local gives it, with the names of months and weekdays of the locale
Locale.time(ms, style: DateStyle = .medium)
String
The time of day of ms in the time zone of the machine
Locale.relative(ms)
String
The text of a difference of ms milliseconds, for example "3 days ago" or "in 2 hours". A negative value is in the past
Locale.list(items)
String
The strings of a List<String> with the separators and the words of the locale, for example "a, b, and c" in en
Locale.plural(n)
PluralCategory
The plural category of n in the locale. n is an Int or a Float
Locale.compare(a, b)
Ordering
The order of two strings in the rules of the locale. .less when a comes before b. xs.sortWith((a, b) => Locale.compare(a, b)) sorts a list in this order (section 4.5)
Locale.set(tag)
nothing
Changes the locale. The tag is a String. The change is not saved
Locale.current
String
The tag of the current locale
  • NumberStyle is a built-in enum with the cases decimal, percent and currency(code: String). code is a currency code of ISO 4217, for example "EUR". A percent shows x times 100: 0.25 gives "25%" in en.
  • A decimal number has at most 3 digits after the separator, and a percent has none. A currency has the digits of the currency, for example 2 for EUR. The value rounds half away from zero, as round does (section 4.2).
  • DateStyle is a built-in enum with the cases short, medium and long. ICU and Intl both have these lengths. The locale gives the pattern of each length.
  • Locale.relative uses the largest unit in which the amount is at least 1. The units are year (365 days), month (30 days), week, day, hour and minute. Else it uses seconds. The amount drops its fraction. A negative value with the amount 0 stays in the past: Locale.relative(-500) is "0 seconds ago" in en.
  • PluralCategory is a built-in enum with the categories of CLDR: zero, one, two, few, many and other. A locale uses only some of them: English has one and other.
  • Locale.plural rounds a Float to 3 digits after the separator first, as the decimal format of Locale.number and Intl.PluralRules do. So Locale.plural(1.0001) is .one in en, and the word fits the number that Locale.number shows.
  • The web uses Intl of the browser. Its data costs no bytes.
  • The native targets and the interpreter use the ICU of the system: libicucore on macOS, icu.dll on Windows 10 version 1903 and later, and libicu on Linux (ADR-0132). An app carries no locale data. An app that does not use Locale does not load ICU.
  • An app that uses Locale loads ICU when it starts. When the system has no ICU, the app stops at its start with an error that names the package of ICU (ADR-0132).
  • The browser and the system can have different versions of ICU and of CLDR, so a few results can differ between the targets (see native differences).

15. Errors

15.1 Compile errors

Each compile error gives the file, the line, the column, a message and, where possible, a suggested fix.

15.2 Runtime errors

The text of a runtime error is runtime error: and a text of this table. The text is the same on each target.
Text
Cause
the result is outside the integer range
An Int or Int64 result outside the range (section 4.2)
the divisor is zero
An integer division or remainder by zero
the index is outside the list
xs[i] or xs.insert(i, x) with an index outside the list
the Float is NaN or outside the Int range
f.toInt() with NaN or a value outside the range
two rows have the same key
Two equal keys in one #for block or List
the effects started 100 batches in a row
100 batches in a row from effects (section 10.5)
the message of the call
A call to panic(message)
  • A runtime error stops the current batch. The writes of the batch before the error stay in effect.
  • In a development build, the app shows the error and its source location on top of the UI. This covers an error in the mount of the app and in the on error handler. An on error handler takes an error first, and the error then shows only when the handler fails. surf dev does this on the web and in the dev host of the desktop (ADR-0122).
  • In a release build, the runtime calls the on error handler of the app. The default handler logs the error and ends the app.
  • A #try block catches an error of the mount or of an update of its body (section 9.5). This error does not reach on error, and the batch goes on.
  • on error(message) { ... } is valid only in the root component, App.surf. message is the text of the error, for example "runtime error: the divisor is zero". The handler runs as a new batch after the batch with the error, and the app goes on. The parameter is optional: on error { ... }.
  • An error in the handler ends the app. On the web, an app that ended takes no more events.
  • On the native targets, a release build of an app with on error unwinds its panics, and a release build without it aborts. Unwinding makes the binary larger: about 100 KB on macOS.
  • The behavior is the same on all targets.

16. Grammar summary

ebnf
component = ( componentItem* "---" )? markup ;module = moduleItem* ;moduleItem = import | const | typeDecl | enumDecl | fnDecl | externDecl | traitDecl | implDecl ;componentItem = import | const | typeDecl | enumDecl | fnDecl | componentDecl | propDecl | stateDecl | derivedDecl | motionDecl | sequenceDecl | effectDecl | eventDecl | slotDecl | lifecycle ;import = "import" ( Name | "{" ident ( "," ident )* "}" ) "from" string ";" ;const = "pub"? "const" ident ( ":" type )? "=" expr ";" ;typeDecl = "pub"? "type" Name typeParams? "=" "{" field ( "," field )* ","? "}" ;field = ident ":" type ( "=" expr )? ;enumDecl = "pub"? "enum" Name typeParams? "{" case ( "," case )* ","? "}" ;case = ident ( "(" field ( "," field )* ")" )? ;fnDecl = "pub"? "async"? "fn" ident typeParams? "(" ( selfParam ( "," params )? | params? ) ")" ( "->" type )? ( block | "=" expr ";" ) ;params = param ( "," param )* ;param = ident ":" type ( "=" expr )? ;externDecl = "pub"? "extern" "module" Name "{" ( externFn | externEvent )* fallback? "}" ;externEvent = "event" ident ( "(" params? ")" )? ";" ;externFn = "async"? "fn" ident "(" params? ")" ( "->" type )? ";" ;fallback = "fallback" "{" fnDecl* "}" ;typeParams = "<" typeParam ( "," typeParam )* ">" ;typeParam = Name ( ":" bound )? ;bound = traitRef ( "+" traitRef )* ;traitRef = Name ( "<" type ">" )? ;traitDecl = "pub"? "trait" Name "{" traitItem* "}" ;traitItem = "type" Name ";" | fnSig ";" | fnDecl ;implDecl = "impl" typeParams? ( type "for" )? type "{" implItem* "}" ;implItem = "type" Name "=" type ";" | fnDecl ;fnSig = "fn" ident typeParams? "(" ( selfParam ( "," params )? | params? ) ")" ( "->" type )? ;selfParam = "mut"? "self" ;componentDecl = "component" Name typeParams ";" ;propDecl = "bindable"? "prop" ident ":" type ( "=" expr )? ";" ;stateDecl = "state" ident ( ":" type )? "=" expr ";" ;derivedDecl = "derived" ident ( ":" type )? "=" expr ";" ;motionDecl = "motion" ident ( "(" motionArg ( "," motionArg )* ")" )? "=" expr ";" ;motionArg = "ms" ":" integer | "ease" ":" string ;sequenceDecl = "sequence" ident "(" sequenceArg ( "," sequenceArg )* ")" "{" cueDecl* "}" ;sequenceArg = "play" ":" expr | "timeline" ":" string | "range" ":" string ;cueDecl = "cue" ident ( "(" cueArg ( "," cueArg )* ")" )? "=" string ";" ;cueArg = ( "ms" | "gap" | "stagger" ) ":" "-"? integer | ( "ease" | "from" ) ":" string | "with" ":" ident ;effectDecl = "effect" block ;eventDecl = "event" ident "(" params? ")" ";" ;slotDecl = "slot" ident "?"? ( "(" slotParam ( "," slotParam )* ")" )? ";" ;slotParam = "bindable"? ident ":" type ;lifecycle = "on" ( "mount" | "destroy" | "error" ) block | "on" "change" "(" args ")" block | "on" "key" "(" ident ( "," ident )? ")" block | "on" ident "." ident [ "(" [ ident { "," ident } ] ")" ] block ;type = ( Name ( "<" type ( "," type )* ">" )? | "(" ( type ( "," type )* )? ")" "->" type | "dyn" Name ( "<" dynArg ( "," dynArg )* ">" )? | "impl" bound ) "?"? ;dynArg = type | Name "=" type ;markup = node+ ;node = element | text | interp | block | tag ;element = "<" Name ( "<" type ( "," type )* ">" )? attr* ( "/>" | ">" node* "</" Name ">" ) ;attr = ident ( "=" ( string | "{" expr "}" ) )? | "{" ident "}" | ( "on" | "bind" ) ":" ident "=" "{" expr "}" | "style" "=" '"' styleItem* '"' | "{" "..." expr "}" ;styleItem = ( styleMark ":" )* ( utility | "@" ident | "(" styleItem* ")" | caseList ) ;styleMark = variant | "{" expr "}" ;caseList = "{" ( ident ":" styleItem* "," )* "}" ;utility = "-"? word ; (* layout spec, section 12 *)interp = "{" expr "}" ;block = ifBlock | forBlock | matchBlock | awaitBlock | tryBlock | fillBlock ;ifBlock = "#if" expr body ( "else" "if" expr body )* ( "else" body )? ;forBlock = "#for" ident ( "," ident )? "in" expr ( "key" expr )? body ( "empty" body )? ;matchBlock = "#match" expr "{" ( pattern "=>" ( body | element | block | tag ) )* "}" ;awaitBlock = "#await" expr body ( "then" ident? body )? ( "catch" ident? body )? ;tryBlock = "#try" body "catch" ident? body ;fillBlock = "#fill" ident ( "(" ident ( "," ident )* ")" )? body ;body = "{" node* "}" ;tag = "@slot" ident ( "(" args ")" )? | "@const" ident "=" expr ; (* to the end of the line *)
The grammar is a summary. It leaves out the statements, which end with ; (section 3.5). The sections above are the normative text where the two disagree. on error in lifecycle is valid only in the root component of an app.Only a function in an impl block or in a trait can have selfParam. In a dyn type, each type of dynArg comes before each Name "=" type.

17. Complete examples

17.1 Counter

surfRun
prop label: String = "Clicks";state count = 0;derived doubled = count * 2;---<Column style="p-6 gap-3 items-center bg-$surface"> <Text style="text-20 font-600">{label}: {count}</Text> <Text>Doubled: {doubled}</Text> <Button on:press={count += 1}>Add one</Button> #if count > 10 { <Text style="text-$warning">That is a lot of clicks</Text> }</Column>

17.2 Todo list

Module files have no mutable data, so the next id is a state in the component.
surf
// model.surfspub type Todo = { id: Int, title: String, done: Bool = false }pub fn remainingText(todos: List<Todo>) -> String { const n = todos.filter(t => !t.done).count(); return n == 1 ? "1 task left" : "{n} tasks left";}
TodoApp.surf:
surf
import { Todo, remainingText } from "./model.surfs";state todos: List<Todo> = [];state draft = "";state nextId = 1;fn add() { if draft.trim().isEmpty() { return; } todos.push({ id: nextId, title: draft.trim() }); nextId += 1; draft = "";}---<Column style="gap-2"> <Row style="gap-2"> <Input style="grow" bind:value={draft} placeholder="New task" on:submit={add} /> <Button on:press={add} disabled={draft.trim().isEmpty()}>Add</Button> </Row> <List items={todos} key={todo => todo.id}> #fill row(todo) { <Toggle bind:checked={todo.done}>{todo.title}</Toggle> } </List> <Text style="text-$text-secondary">{remainingText(todos)}</Text></Column>

18. Decisions for version 1

On 2026-11-04, the project owner decided the open questions of version 1:
  • The formats of a locale: the module Locale, valid in each function, and the sort sortWith (sections 4.5 and 14, ADR-0132). The details are in sections 4.5, 7.3, 10.2, 10.4 and 14: no Locale in a module constant, one stable merge sort, and the lookup of RFC 4647.
  • No function types in extern modules. Events and async fn calls do the work of a callback (section 13.1, ADR-0133).
  • Traits and impl blocks in the form of Rust (section 7.8, ADR-0134).
  • Type parameters with bounds, generic components and trait objects. == on a dyn value is a compile error (section 7.9, ADR-0135).
On 2026-11-05, the project owner answered the open questions of the language for version 1:
  • A module file has a name in camelCase, and a component file has a name in PascalCase. A wrong name and two paths that differ only in case are compile errors (section 2.2, ADR-0140).
  • Each keyword is valid as a field name, also the six keywords of traits (section 3.2, ADR-0134).
  • The web compares strings by UTF-16 code units, as JavaScript does. The native targets keep the order of scalar values (section 4.4, ADR-0113).
  • ease of motion takes only the name of a utility, for example "ease-spring" (section 7.5, ADR-0104).
  • A file calls a method of a trait only when it imports the trait (sections 7.2 and 7.8, ADR-0134).
  • The other rules of ADR-0113 stay (points 1, 2, 5 and 6), and so do points 1 to 8 of ADR-0115.
These items come after version 1:
  • Function types in extern modules. They need a real case and a new ADR (ADR-0133).