WCL Reference · tutorial

Quick start

10 min read · 2026-08-20 · wcl 0.33.2-alpha

This is the shortest complete path through WCL. You install the tool, write one file, watch the tool refuse it, give it a schema, read the document back, and then tighten the schema until it catches the mistakes a data format would have waved through. It closes by building a small wdoc site with the same binary.

Every file and every command below was run before it was written down. Type them and compare. Introduction covers what WCL is for and how it compares with JSON, YAML and TOML.

Before you start

Install

WCL is pre-release only for now, so ask the install script for the newest pre-release. A plain run targets stable, which does not exist yet:

bash
$ curl -fsSL https://wcl.dev/install.sh | sh -s -- --pre

That drops a single binary into ~/.local/bin (override with --bin-dir <dir> or $WCL_INSTALL_DIR) and tells you if that directory is not on your PATH. Prebuilt binaries exist for Linux on x86_64. On anything else, macOS included, build from source with Cargo, which needs a Rust toolchain:

bash
$ cargo install --git https://github.com/wiltaylor/wcl -p wcl --locked

Either way, confirm the result before you go on:

console
$ wcl --version
wcl 0.33.2-alpha

One binary is the whole toolchain. wcl is the parser, the checker, the evaluator, the formatter, the differ, the project scaffolder, the language server your editor talks to, and the wdoc site builder. The CLI documents every subcommand.

Your first document

Save the data half of the comparison in the introduction on its own, without the types. Call it app.wcl:

app.wclwcl
service = "checkout"

env staging {
  url      = "https://staging.example.com"
  replicas = 1
}

That is a legal WCL file. It parses: a field, then a block with a label and two fields of its own. It is not a legal WCL document, and wcl check says so twice:

console
$ wcl check app.wcl
wcl::eval::schema_violation

  × top-level field 'service' has no @document schema

wcl::eval::schema_violation

  × block kind 'env' has no @block or @table declaration

app.wcl: 2 schema violations

There is no schema-free document

That refusal is the first rule of the language, and it is worth meeting head-on. Data needs a schema to stand on: a top-level field that no @document type declares is an error, and so is a block kind that no @block or @table type declares. A file that holds nothing but types, let bindings and functions is fine. That is a library, not data. Every complete example in this book therefore carries its schema, and so will yours.

A first schema

A schema is four decorators and two type declarations. Put them at the top of the same file. Order does not matter to the parser, but it reads better with the shape before the data. The listing below is the file from the comparison in the introduction, assembled, with a comment on each half:

app.wclwcl
# app.wcl — how the checkout service is deployed, per environment.

# What an environment is.
@block("env")
type Env {
  @inline(0) name: identifier
  url:      utf8
  replicas: u32
  debug:    bool
}

# What the file as a whole may contain.
@document
type AppConfig {
  service: utf8
  @children("env") envs: list<Env>
}

service = "checkout"

env staging {
  url      = "https://staging.example.com"
  replicas = 1
  debug    = true
}

env prod {
  url      = "https://example.com"
  replicas = 6
  debug    = false
}

Read the decorators one at a time. @block("env") says: you write instances of this type as blocks whose kind is env. @inline(0) binds the first label, the thing between the kind and the brace, to the name field, so env staging is an environment named staging. @document marks the type that describes the file's top level. @children("env") declares a gather field: envs is written nowhere in the data. It is the list every env block lands in.

A label identifies a block. It does not become an ordinary field on the block, so wcl eval app.wcl envs.staging.name answers cannot evaluate type_field as a leaf value rather than staging. Documents, fields and blocks covers labels properly.

console
$ wcl check app.wcl
OK

That OK is the whole promise of the language in two characters. Nothing consumed the file. Nothing booted. wcl check held the document against a shape declared in the same breath as the data, and it fit.

Reading the document back

wcl eval resolves a dotted path from the document root and prints one value:

console
$ wcl eval app.wcl service
"checkout"
$ wcl eval app.wcl envs.prod.replicas
6

wcl get is an alias for the same command, and the rest of this book mostly writes it that way. get reads better when you are fetching one value, and eval reads better when the value is computed. Add --json to print a JSON scalar instead of the WCL display form.

Read the path envs.prod.replicas from the left. envs is the gather field the @document schema declared. prod picks one of the gathered blocks by its label. replicas is a field on that block. A path has to end on a leaf: envs.prod gives cannot evaluate block as a leaf value, because a block is not a value.

To see the whole tree at once, use wcl parse. It prints the evaluated document, what your file became rather than what you typed:

console
$ wcl parse app.wcl
@block("env")
type Env {
  @inline(0)
  name: identifier
  url: utf8
  replicas: u32
  debug: bool
}
... then your @document type, then the decorator, unit and symbol_set
declarations every document gets for free, then the data ...
service = "checkout"
env staging {
  url = "https://staging.example.com"
  replicas = 1
  debug = true
}
env prod {
  url = "https://example.com"
  replicas = 6
  debug = false
}

Two things there are worth a second look. Your own type declarations come back normalized, with @inline(0) on its own line and url: utf8 stripped of its padding, because parse prints the tree, not your keystrokes. wcl fmt is the same normalization written back to the file. And the declarations you never wrote are the built-in decorators, units and symbol sets every document gets for free. That is where @block, @children and 512MiB come from.

Three commands, three questions. wcl check asks is this document valid. wcl eval asks what is this one value. wcl parse asks what did the whole file become. The CLI covers the rest: set, fmt, diff, init, repl, lsp.

Making the schema stricter

So far the schema says replicas is a u32. That is thinner than it looks: it does not fix a sensible range, it does not say that an environment belongs to a billing tier, that debug defaults to off, or that url is not optional. Each of those is one more line. Replace the type declarations. The data below them does not change:

app.wclwcl
symbol_set Tier { free  pro  enterprise }

@min(0)
@max(1000)
type Replicas = u32

@block("env", required_fields = ["url", "replicas", "tier"])
type Env {
  @inline(0) name: identifier
  url:      utf8
  replicas: Replicas
  tier:     Tier
  @default(false) debug: bool
}

@document
type AppConfig {
  service: utf8
  @children("env") envs: list<Env>
}

Four ideas, in order. A symbol_set is a closed vocabulary: tier may be :free, :pro or :enterprise and nothing else. A type alias with @min / @max narrows a primitive and gets a name that says what it is for. required_fields lists the fields a block may not leave out. @default(false) supplies a value when a field is absent, so debug can stay off that list. The label name is off it too, because you do not write a label as a field at all.

Now give it an environment that breaks the schema in two places at once:

wcl
env prod {
  url      = "https://example.com"
  replicas = -1
  tier     = :platinum
}
console
$ wcl check app.wcl
wcl::eval::schema_violation

  × field 'replicas': value -1 is below @min(0)

wcl::eval::schema_violation

  × field 'tier' declared as symbol_set 'Tier' but ':platinum' is not one of
  │ its members

app.wcl: 2 schema violations

Delete the url line as well and a third violation joins them: block 'env' is missing required field 'url'. Each of these is the class of mistake a data format hands to the program at startup, and a command that never ran the program caught every one of them. wcl check exits non-zero, so a pre-commit hook or a CI step is one line.

Two more mistakes worth trying on the corrected file, because they show what the checker looks at. Writing replicas = "6" gives field 'replicas' declared as Replicas but value is utf8. A string that looks like a number is still a string, and the message names your alias rather than the u32 underneath it. Mistyping the field as replicass = 6 gives two violations at once: field 'replicass' is not declared by schema 'Env', because wcl check refuses an unknown field instead of ignoring it, and block 'env' is missing required field 'replicas', because the field you meant to write is now absent.

None of this stops you reading the file. wcl eval app.wcl envs.prod.replicas still answers -1, and still exits 0. Resolving a path asks what a value is, never whether it is allowed, so a field can be out of range and readable at the same time. Validation is wcl check's job alone. That is the command to put in the pre-commit hook.

Not everything waits for wcl check

Fields evaluate lazily, so an error inside an expression surfaces when something asks for the value rather than when wcl check runs. debug = no passes wcl check, because no is a name and the schema does not resolve names. It then fails on wcl eval with unresolved reference 'no'. How a document evaluates covers which errors land where, and why.

Schemas is the full account of @document, @block, @child, @children, @inline, @table and reference checking. Decorators covers the whole decorator vocabulary, including how to declare your own.

Your first wdoc site

wdoc is a document generator written in WCL, and it is a schema rather than a second language. import <wdoc.wcl> pulls in a library of type declarations shipped inside the binary, and every block it gives you is an ordinary @block type of the kind you just wrote by hand: site, page, h1, p, code, table and callout. Save this as main.wcl in an empty folder:

main.wclwcl
import <wdoc.wcl>

site handbook {
  title            = "Checkout handbook"
  default_template = :book

  toc {
    chapter "Overview" { page = overview }
  }
}

page overview {
  start = true
  title = "Overview"

  h1 "Overview"

  p "The checkout service runs in two environments."

  table {
    rows:
      | "Environment" | "Replicas" |
      | "staging"     | "1"        |
      | "prod"        | "6"        |
  }
}
console
$ wcl wdoc build main.wcl --out _site
wrote 1 page

That is a complete site: _site/index.html, _site/overview.html, and the fonts and other assets beside them under _site/_wdoc/. wcl wdoc serve main.wcl serves the same build over HTTP while you write. It watches your sources and collects what changed. Press Enter in its console to rebuild, and the browser reloads itself.

Now point the language half at the document half. Nothing special happens, and that is the point:

console
$ wcl check main.wcl
OK
$ wcl get main.wcl pages.overview.title
"Overview"
$ wcl get main.wcl sites.handbook.title
"Checkout handbook"

The same checker, the same paths, the same errors. Mistype h1 as h7 and you get block kind 'h7' is not allowed inside 'page': the message you would get for any block a schema does not admit, because that is exactly what happened.

For a running start on either half, wcl init scaffolds a project folder from a template. wcl init --list names the built-ins, which include a minimal single-file project and four ready-made wdoc ones (page, book, website, presentation).

Where to go next