WebFluent has a type checker that runs in wf build, wf check and the
editor, before any output is written. It is gradual: a program that
declares no types still checks, because whatever the checker cannot work
out has the type Any, and Any agrees with everything. Every annotation
you add lets the checker say more. It is also structural: a value fits a
type when it has the right fields, whatever it was declared as. The checker
changes nothing in the output; it only reports.
In this chapter
- The type language, and the types that come with the language
- Records (
type), enums and the shapes of map literals - Where the checker gets a type from, and how it infers one
- Narrowing a value that may be
null - Typing data from the network
- The checks, and what the checker does not check
This page declares an Invoice record and builds one with the wrong kind
of value for total:
type Invoice { id: String, total: Number, paid: Bool = false }
page Invoices(path: "/", title: "Invoices", description: "What is owed.") {
state invoice: Invoice = Invoice(id: "INV-001", total: "120")
Heading("Invoice {invoice.id}").h1
Text("Owed: {invoice.total}")
}
The build stops, pointing at the field:
error[T01]: `total` of `Invoice` is `Number`, but `"120"` is `String`
--> src/App.wf:4:53
Writing total: 120 fixes it. Had the invoice been an untyped map,
{ id: "INV-001", total: "120" }, the page would build, and
invoice.total would be a string at run time.
| Type | Written | Values |
|---|---|---|
| String | String |
"text", "with {interpolation}" |
| Number | Number (Int and Float are accepted as other spellings of it) |
42, 3.14 |
| Bool | Bool |
true, false |
| List | [String], [Todo] |
[a, b] |
| Map | Map |
{ key: value } — an object with unknown keys |
| Optional | String?, Todo? |
a value or null |
| Record | the type's name |
Todo(id: "a", title: "b") |
| Enum | the enum's name |
.case |
| Any | Any |
anything; what is not known |
You write a type after a colon on a state, a prop, an action parameter, a
type field, a route parameter, a let or a resource. This page uses
several:
page Typed(path: "/") {
state name: String = ""
state count: Number = 0
state tags: [String] = []
state picked: String? = null
state profile: Map = {}
action greet(who: String, times: Number) { log("hi {who} × {times}") }
derived pick = picked ?? "none"
Text("{name} {count} {tags.length} {pick}")
Button("Go") { on click { greet(name, count) } }
}
Eleven more types come with the language. Each is a plain JSON value at
run time, with no wrapper object, so it crosses a fetch, a persist,
the static paint (the HTML the build pre-renders for a page) and the
template engine unchanged. .native() hands a real platform object to a
library that wants one.
| Type | Carried by | Written as a literal |
|---|---|---|
Date |
"2026-03-14" |
@2026-03-14 |
Time |
"09:30" |
@09:30 |
DateTime |
ISO 8601 | @2026-03-14T09:30Z |
Duration |
milliseconds | 3.days, 90.minutes, 250.ms |
Money |
{ amount: 1299, currency: "EUR" } |
€12.99, $9.99, £4.50 |
Url |
a string | "https://example.com" |
Email |
a string | "ada@example.com" |
Color |
a string | #0F766E |
Uuid |
a string | uuid() makes a fresh one |
File |
the browser's File |
what a FileUpload yields |
Secret |
a string | — |
Money is held in minor units — cents, pence — so its arithmetic is
whole-number arithmetic and nothing drifts. A duration is milliseconds, so
it adds to a moment. uuid() is where a Uuid comes from: it uses the
platform's own generator where there is one, and otherwise builds a
version-4 UUID from the best randomness available.
This page books a stay with dates, money and a refined number:
type Booking {
id: Uuid
guest: Email
from: Date
nights: Number(1..=30)
total: Money
}
page Stay(path: "/", title: "Stay", description: "One booking.") {
state from: Date = @2026-03-14
state nights: Number = 5
state total: Money = €249.00
derived checkout = from.plus(days: nights)
derived weekday = checkout.weekday()
derived late = now.date().isAfter(checkout)
Heading("Your stay").h1
Text("Out on {checkout}, a weekday of {weekday}")
Text("Due {format(total)}")
if late { Alert("Checkout has passed").warning }
}
Everything the language computes is a call; everything the carrier
already has is a field. So total.amount and total.currency are
fields, and from.year() and checkout.weekday() are calls.
| Type | Methods |
|---|---|
Date Time DateTime |
.year() .month() .day() .weekday() .hour() .minute() .second(), .plus(days: 3) and .minus(…) (which take years months weeks days hours minutes seconds ms), .isBefore(d) .isAfter(d) .isSame(d), .until(d) (a Duration), .startOfDay() .startOfWeek() .startOfMonth() .endOfDay(), .native(); DateTime also .date() .time() .inZone("Europe/Berlin") |
Duration |
.days() .hours() .minutes() .seconds() .ms(), .plus(d) |
Money |
.plus(m) .minus(m) .times(n) .convert(rate, "USD"); format(m) needs no style and no currency code |
Url |
.host() .path() .query(), .with(query: { page: 2 }) |
Email |
.domain() |
Color |
.mix(other, 0.2) .lighten(0.1) .darken(0.1) .alpha(0.5) .contrast(other) |
File |
.preview() — an object URL, revoked when the scope that made it leaves; the fields .name .size .type |
Adding a month lands on a day that exists: @2026-01-31.plus(months: 1) is
the 28th of February, not the 3rd of March. All of these methods also run
at build time, so the static paint shows the answer instead of a blank that
fills in when the page hydrates.
A string written where one of these types is wanted is read as that type, and checked where you wrote it:
state site: Url = "https://example.com" // fine
state bad: Url = "example" // T01: A URL has a scheme
now is a DateTime that keeps itself current. It updates every minute by
default, or as often as a page asks with now(every: …):
page Feed(path: "/", title: "Feed", description: "What happened lately.") {
state posted: DateTime = @2026-03-14T09:30:00Z
derived age = posted.until(now)
Text("{ago(posted)} — {age.minutes()} minutes")
Text("ticking: {now(every: 1.seconds)}")
}
Read now where it is shown or in a derived value. A state takes its
initial value once, so state clock = now(every: 1.seconds) stays at the
moment the page opened (X06; see
State and reactivity).
Any type may say what its values must be. The compiler holds every value it can read to that condition:
state nights: Number(1..=30) = 12
state pass: String(minLength: 8) = "correcthorse"
state start: Date(after: @2026-01-01) = @2026-03-14
The conditions are min, max, minLength, maxLength, pattern,
after and before, and a range for the two ends at once: 1..=30
includes the end, 0..10 excludes it. A value that arrives at run time —
typed into a field, say — is refused by validation, from the same
condition, so a refined type needs no validate rule for it (see
Forms).
A Secret is a string the compiler will not let escape. Each of these
lines is a T12:
state token: Secret = ""
Text(token) // shown
Text("Bearer {token}") // in text — which is how it ends up in a URL
persist token: Secret = "" // kept in the browser
log(token) // in the console, and often in a log service
Joining it with + or handing it to a browser global such as console, a
storage or a fetch address is a T12 too, and a secret has no string
members to read. Hold it in state, which ends with the visit; what must
outlive the visit belongs on the server.
A type the program declares takes the name back from the language. A
project with its own type Color { name: String } means that record
everywhere it writes Color.
A record is a value with named, typed fields, declared with type:
enum Tone { neutral, info, danger }
type Todo {
id: String
title: String
done: Bool = false
tone: Tone = .neutral
note: String?
}
type Admin = Todo { owner: String }
page Records(path: "/") {
state todos: [Todo] = [
Todo(id: "1", title: "Write the docs"),
Todo(id: "2", title: "Ship", done: true, tone: .info, note: "Friday"),
]
state special: Admin = Admin(id: "3", title: "Review", owner: "sam")
for todo in todos by todo.id {
Row(gap: .sm) {
Text(todo.title)
if let n = todo.note { Badge(n).info }
}
}
Text("{special.owner} owns {special.title}")
}
- You may separate fields with newlines or commas.
- You build a record by naming its fields:
Todo(id: "1", title: "x"). A field with a default may be left out, and aT?field defaults tonull. Leaving out a field with no default isC01. - A record whose fields can all be left out is built with none given,
Offers(), wherever a value goes: a prop's default (component Card(offers: Offers = Offers())), a field's default, aconst, astate, an argument. type Admin = Todo { owner: String }extends a record: anAdminhas every field ofTodo, then its own.- Records are structural:
{ id: "a", title: "b" }given where aTodois wanted is aTodo, and its fields are checked against the record's. - A
Mapfits any record, and a record fits aMap. So JSON fromfetchfits atypeit was never declared as.
An enum is a type with a fixed set of cases. A case may carry a payload — values that travel with it:
enum Size { sm, md, lg }
enum Status { idle, running(job: String), failed(reason: String), done(count: Number) }
page Enums(path: "/") {
state size: Size = .md
state s: Status = .idle
Button("Bigger") { on click { size = .lg } }
Button("Start") { on click { s = .running("build-42") } }
Text(match size { .sm { "small" } .md { "medium" } else { "large" } })
match s {
.running(job) { Text("Running {job}") }
.failed(reason) { Alert(reason).danger }
.done(n) { Text("{n} done") }
else { Text("Idle") }
}
}
- You write a case as
.name. On its own it is a case of an enum not yet known; where aSizeis wanted it becomes aSize, and.hugethere isT02. - A case with a payload,
.failed("x"), is checked like a call. Amatcharm binds the payload:.failed(r)givesrthe typeString. - At run time a bare case is its name and a case with a payload is a list,
["failed", "timed out"], sosize == .lgstill compares cases. - A
matchwith noelsemust give every case an arm (T15). - An enum prop is written to a component's root element as
data-<prop>(see Enum props).
A map literal has the fields it was written with. { name: "", age: 0 }
has the shape { name: String, age: Number }, and reading form.nam is
T05, with the fields it does have. A shape fits a Map, a record with the
same fields, and any shape it agrees with. An empty {} and a literal with
a spread say nothing about their keys, so they are plain Maps.
page Shapes(path: "/") {
state form = { name: "", age: 0, tags: ["new"] }
Input(bind: form.name, label: "Name")
Text("{form.name} is {form.age} with {form.tags.length} tags")
}
The checker reads a type from five places.
Declared: annotations, a type's fields, an enum's cases, a page's
route parameters, an event's parameters, a component's props.
Inferred: a literal; a list of literals ([1, 2] is [Number], []
is [Any]); a record construction; a derived value from its expression;
a state from its initial value (one that starts as null is Any?); a
for binding from the list's item type; a match arm's binding; an
if let binding; a lambda's parameters from what the method expects
(items.filter(item => …) gives item the item type).
From the registry: the type of every built-in prop, and what a bind:
needs — Checkbox and Switch a Bool, Slider a Number, Input a
String or a Number by its input type.
From the method tables:
- On a
String:trim,toLowerCase,toUpperCase,replace,slice,padStart,repeat,capitalize,truncate(n)→String;length,indexOf→Number;includes,startsWith,endsWith→Bool;split→[String]. - On a list:
map→[U]from the lambda;filter,slice,concat,sort,sortBy,unique,take,flatMap→[T];find,first,last→T?;some,every,includes→Bool;length,sum,indexOf,findIndex→Number;join→String;groupBy→Map;reduce→ the initial value's type. - On a
Number:toFixed,toString→String.
Any other method is Any. The full lists are in
Built-ins.
From stores: Todos.items has the type of the store's state, and
Todos.add the signature of its action.
Browser globals (window, document, JSON, Math, Date, …) are
Any.
The checker works in both directions. When the context wants a type, it
checks the expression against that type; otherwise it reads the type off
the expression. The comments show what each derived value is inferred to
be:
type Row { id: Number, label: String, score: Number? }
page Infer(path: "/") {
state rows: [Row] = [Row(id: 1, label: "a"), Row(id: 2, label: "b", score: 9)]
derived labels = rows.map(r => r.label) // [String]
derived best = rows.find(r => r.score != null) // Row?
derived total = rows.reduce((n, r) => n + (r.score ?? 0), 0) // Number
derived kind = if total > 5 { "high" } else { "low" } // String
derived firstScore = best?.score ?? 0 // Number
derived joined = labels.join(", ") // String
Text("{joined} {kind} {firstScore}")
}
The rules for operators and forms:
a ?? bisawithout itsnull, joined withb.a?.bisb's type made optional, and the rest of the chain with it.ifandmatchused as values have the type both arms fit: the same type,T?when one arm isnull, andAnyotherwise.-,*,/and%give aNumber;+gives aStringwhen either side is one; comparisons and&&,||,!give aBool.await eon afetchisAnyunless you annotate it:let r: [Row] = await fetch(…).let { a, b } = mandlet [x, y] = lbind the fields and the items.
A value of type T? may be null, so you must narrow it — prove it is
not null — before you read a field or method off it. Reading one without
narrowing is T04. Each of these forms narrows a name within its branch:
type Todo { id: String, title: String, note: String? }
page Narrow(path: "/") {
state sel: Todo? = null
state todo: Todo = Todo(id: "1", title: "x")
if let n = todo.note { Text("{n.length} chars") }
if sel != null { Text(sel.title) }
if sel { Text(sel.title) }
derived label = if sel != null { sel.title } else { "none" }
Text(todo.note ?? "no note")
Text(sel?.title ?? "nothing selected")
Button("Pick") { on click { sel = todo } }
}
Narrowing follows the flow of a block: it holds in the branch the condition
guards, not after it. A check for null also narrows the else branch,
and the code after if x == null { return }. An item at a fixed index,
such as todos[0], may be missing, so it counts as T? unless a condition
like todos.length > 0 encloses it.
A list narrows too: xs.filter(x => x != null) is a list of the items that
are there, so a [Todo?] filtered that way is a [Todo] whose items need
no ?..
Warning
Splicing a value that may be null into text shows the word null on
the page. The checker warns (T19); say what to show instead with ??,
as in Text(todo.note ?? "no note").
fetch returns Any, because the checker cannot see what a URL will
return. Annotate the resource or the local variable to type its value:
type Deploy { id: String, status: String, startedAt: String }
page Deploys(path: "/") {
resource deploys: [Deploy] = fetch("/api/deploys")
match deploys {
loading { Spinner }
error(e) { Alert("Failed: {e.message}").danger }
ready(rows) {
for d in rows by d.id { Text("{d.id} {d.status} {ago(d.startedAt)}") }
}
}
}
The checker trusts the annotation. A development build (wf serve) also
holds each response to its declared type, and a mismatch is a .parse
error that names the place, such as $[1].status is missing.
When the server publishes an OpenAPI specification, let the compiler read
it instead: api Backend from "openapi.json" types every endpoint,
parameter, response and error from the file, and turns each named schema
into a type, or an enum for a string with cases (see
Data and APIs). An api you write by
hand types its calls the same way: get users() -> [User].
The type checker reports these codes. All are errors except T19, T20
and T21, which are warnings.
| Code | When |
|---|---|
T01 |
A value of the wrong type given to a prop, a state, a field, a parameter, an assignment, an argument, or a bind: |
T02 |
A case the enum does not have |
T04 |
A field or method read off a value that may be null |
T05 |
A field or method a record or shape does not have |
T06 |
A member or action a store does not have, or an endpoint a service does not have |
T07 |
A list, a record or an action used as a condition, which is always true |
T08 |
A for over something that is not a list |
T09 |
An emit whose arguments do not match the event's |
T10 |
A call with the wrong arguments: too many or too few, a name the callee does not take, or one of the wrong kind |
T11 |
A match on something that is neither a resource nor an enum, or an arm the value cannot take |
T12 |
A Secret where it would escape — shown, spliced into text, joined with +, handed to a browser global, logged, or kept with persist |
T13 |
A name, or a function called, that nothing declares — which the browser would throw on |
T14 |
A comparison that is always the same: "1" == 1, a record against a string, an enum against a case it does not have |
T15 |
A match with no else that misses a case of the enum, or has a case twice |
T16 |
A method a number, a string or a list does not have, with the nearest one it does |
T17 |
An async action's result used before it is awaited, or await where it cannot run |
T18 |
Arithmetic on something that is not a number |
T19 |
A value that may be null spliced into text (warning) |
T20 |
A list or a record shown as text (warning) |
T21 |
A resource match with no error arm and no else (warning) |
T22 |
A handler that names more values than its event sends |
Most findings come with a hint: a conversion (Number(value),
"{value}"), the cases an enum takes, the fields a record has, or one of
the narrowing forms above. wf explain T05 prints a code's full entry, and
Diagnostics has every code with an example.
- It does not check a lambda's body against a return type.
- It does not change a name's type across assignments:
state x = 1stays aNumber, and assigning a string to it isT01. - It does not know what a plain
fetchreturns. Annotate it, or describe the service with anapi.
Hover shows a name's inferred type: state draft: String, derived remaining: Number, Todos.add: action(String). Completion after .
offers a record's fields, an enum's cases where one is wanted, and the
methods of a String, a Number or a list. A type error appears as a
diagnostic on the argument it belongs to. To set up the editor, see
Editors.
| Syntax | Meaning |
|---|---|
state x: T = v |
Annotate a state; props, parameters, fields and let take : T the same way |
[T] · T? · Map · Any |
A list, a value that may be null, an object with unknown keys, anything |
type Name { field: T = default } |
Declare a record |
type B = A { extra: T } |
Extend a record |
Name(field: v) · Name() |
Build a record |
enum Name { a, b(x: T) } |
Declare an enum, with an optional payload per case |
.case · .case(v) |
Write a case |
Number(1..=30) · String(minLength: 8) |
A type with a condition |
if let x = v { … } · v ?? d · v?.f |
Narrow or read through null |
resource r: [T] = fetch(…) |
Type data from the network |