Introduction & Quick Start

WCL is a typed configuration and schema language. A .wcl file carries the data — the ports, the replica counts, the URLs — and it carries the shape that data has to fit. One command, wcl check, holds the second against the first, and it does that before the program needing the configuration has started.

This chapter is the shortest complete path through that idea. It installs the tool, writes one file, watches the tool refuse it, gives it a schema, reads it back, and then tightens the schema until it catches the mistakes a data format would have waved through. It closes on wdoc, the document generator built on the same language, and on how to read the other forty-one chapters.

Every file and every command below was run before it was written down. Type them and compare.

§ 1What WCL is for

Configuration is the part of a system with no compiler. The code around it is type-checked, reviewed and tested; the file that decides which database it talks to is a bag of strings that nothing looks at until the process boots. So a missing key is a crash at 3am, a replicas: 600 is an outage, and a debug: true that outlived its pull request is an incident report.

The usual answer is to describe the shape somewhere else — a JSON Schema file, a Kubernetes CRD, a struct with #[serde(deny_unknown_fields)] in the program that reads it. That works, and it puts the description of the data in a different file, a different language, and often a different repository from the data itself. The two drift.

WCL's answer is that the shape belongs in the language. A .wcl file declares its own types, and the toolchain checks the data against them as an ordinary command you can run in CI, in a pre-commit hook, or on your laptop before you push. Nothing has to consume the file for it to be judged.

What the type system carries, beyond the string/number/boolean of a data format:

§ 2How WCL differs from JSON, YAML and TOML

Take a real fragment. Here is a service deployed to two environments, written the way most projects write it today:

app.yamlyaml
service: checkout

environments:
  staging:
    url: https://staging.example.com
    replicas: 1
    debug: yes
  prod:
    url: https://example.com
    replicas: 6
    debug: no

Nothing in that file says replicas holds a number. Nothing says 600 would be absurd. Nothing says environments may hold staging and prod and not stagng. Every one of those is the consuming program's problem, discovered at startup, in production, by whoever is on call. And debug: yes is not even reliably a boolean: YAML 1.1 parsers read yes and no as true and false, YAML 1.2 parsers read them as the strings "yes" and "no", and which one you get depends on a library you did not choose.

The same data in WCL, with the shape written down beside it:

app.wclwcl
@block("env")
type Env {
  @inline(0) name: identifier
  url:      utf8
  replicas: u32
  debug:    bool
}

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

service = "checkout"

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

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

It is longer, and the extra length is the whole point: the top half is the contract, and it is the reason wcl check can answer a question about the bottom half. There is no debug: yes ambiguity to resolve, because yes is not a boolean in WCL — it is a name, and a name that nothing declares fails to resolve.

QuestionJSONYAMLTOMLWCL
CommentsNoYesYesYes, and wcl fmt keeps them
Scalar typesString, number, bool, nullThe same, guessed from the textPlus dates and timesSized integers, floats, units, bool, four string encodings, identifiers, symbols, none
Where the schema livesAnother file (JSON Schema)Another fileAnother fileThis file, or one it imports
Computed valuesNoAnchors and aliases reuse, never computeNoFull expressions, let bindings and functions
Who validatesThe consuming programThe consuming programThe consuming programwcl check, before anything consumes it
Reusing a fragmentCopy it&anchor / *aliasCopy itimport, let, functions, references

The last row is the one that compounds. A configuration format without composition grows by copying, and a file that grew by copying is a file where two of the copies disagree. Namespaces and imports covers splitting a document across files; Expressions and operators covers computing one field from another.

WCL is not a general-purpose language

There is no I/O, no mutation, no loops that can fail to terminate. Every expression evaluates to a value, and evaluation is lazy and cached — see How a document evaluates. What it adds over a data format is types, composition and checking, not a runtime.

§ 3Install

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:

text
$ 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:

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

Either way, confirm the result before going on:

text
$ 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.

§ 4Your first document

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

app.wclwcl
service = "checkout"

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

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:

text
$ 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

This 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 holding 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.

§ 5A first schema

A schema is three 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. This is the file from the comparison above, 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 = 1u32
  debug    = true
}

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

Read the decorators one at a time. @block("env") says: instances of this type are written 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 describing 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 rather than becoming an ordinary field on it, which is why 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.

text
$ wcl check app.wcl
OK

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

§ 6Reading the document back

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

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

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, eval 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 it. A path has to end on a leaf: asking for 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:

text
$ 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 = 1u32
  debug = true
}
env prod {
  url = "https://example.com"
  replicas = 6u32
  debug = false
}

Two things there are worth a second look. Your own type declarations come back normalized@inline(0) on its own line, url: utf8 with its padding gone — 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, which 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.

§ 7Making the schema stricter

So far the schema says replicas is a u32. It does not say that 600 of them is nonsense, 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(1)
@max(50)
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 carrying @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, which is why debug can stay off that list — the label name is off it too, because a label is not written 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 = 600u32
  tier     = :platinum
}
text
$ wcl check app.wcl
wcl::eval::schema_violation

  × field 'replicas': value 600 is above @max(50)

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'. Every one of these is the class of mistake a data format hands to the program at startup, and every one of them was answered by a command that never ran the program. 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 keys on. 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 = 6u32 gives two violations at once: field 'replicass' is not declared by schema 'Env', because an unknown field is refused rather than ignored, and block 'env' is missing required field 'replicas', because the field you meant to write is now absent.

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 the document is checked. debug = no passes wcl checkno is a name, and the schema does not resolve names — and 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 declaring your own.

§ 8The other half: wdoc

wdoc is a document generator written in WCL. You declare pages and sites as blocks, and wcl wdoc renders them to a static website, a Markdown tree or a PDF. The book you are reading is a wdoc site, and its source is one main.wcl declaring the site and its table of contents, plus one .wcl file per chapter.

The relationship between the two halves is simpler than it sounds: wdoc is a schema. import <wdoc.wcl> pulls in a library of type declarations shipped inside the binary, and every block it gives you — site, page, h1, p, code, table, callout — is an ordinary @block type of the kind you just wrote by hand. 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"        |
  }
}
text
$ 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, you press Enter in its console to rebuild, and the browser reloads itself.

Now point the language half at the document half, and nothing special happens — which is the point:

text
$ 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.

That symmetry is why the two halves share one book. A wdoc page is a WCL document, so everything in Part I applies to it: a page body can compute a heading from a let, a site can import its data from another file, and a repeater can project a list of typed records into one rendered section per record. Documents, pages and sites starts Part II; Data views is where the two halves meet properly; Writing your own blocks shows how to add a block of your own.

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).

§ 9How to read this book

This is one reference for both halves, in three parts.

PartChaptersWhat it covers
Introduction0This chapter.
The WCL language1–14Documents, values, collections, types, expressions, control flow, functions, namespaces, schemas, decorators, connections, evaluation, builtins, and the CLI.
wdoc15–41Pages and sites, templates, themes, visibility and output targets; then the block reference — text, code, callouts, tables, media, icons, math, data views, diagrams, charts, timelines, terminals, wireframes, and writing your own.

Read Part I in order the first time. Chapters 1 to 5 build on one another — Documents, fields and blocks is the shape of a file, Values and primitives is what goes in it, Lists, tensors and records is what those compose into, Types is how you name a shape, and Expressions and operators is how you compute one. After chapter 5 the chapters are largely independent, and Schemas is the one to reach for when a wcl check message surprises you.

Part II assumes Part I and otherwise stands alone. Read Documents, pages and sites first; after that, look up the block you need.

Three conventions run through every chapter:

§ 10Where to go next