Values and primitives

Documents, fields and blocks covered the shapes a document is made of. This chapter covers what goes inside them: the scalar values that may sit on the right of an =, in a block label, or in a table cell. There are seven ways to write one — numbers, unit literals, booleans, identifiers, symbols, strings and none — and the whole chapter is those seven, in that order. Everything composite (lists, tensors, records, variants) is Lists, tensors and records.

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

§ 1One file, every primitive

Save this as release.wcl. It is a release manifest, and it uses every primitive value the language has:

release.wclwcl
# release.wcl — one release, written with every primitive WCL has.

symbol_set Channel { alpha  beta  stable }

@unit("g", 1)
@unit("kg", 1000)
type Mass = i64

@block("artifact")
type Artifact {
  @inline(0) name: identifier
  size:     std.ByteSize
  checksum: ascii
  signed:   bool
  notes:    utf8?
}

@document
type Release {
  version:  utf8
  build:    u32
  channel:  Channel
  ratio:    f64
  timeout:  std.Duration
  shipping: Mass
  banner:   utf8
  @children("artifact") artifacts: list<Artifact>
}

version  = "2.4.0"
build    = 0x1F4u32
channel  = :stable
ratio    = 0.75
timeout  = 90s
shipping = 12kg
banner   = $"wcl ${version} (build ${build}) on ${channel}"

artifact wcl_linux {
  size     = 18MiB
  checksum = ascii"9f2c41ab"
  signed   = true
}

artifact wcl_windows {
  size     = 21MiB
  checksum = ascii"c30dd5e7"
  signed   = false
  notes    = <<'TEXT'
    Unsigned: the \CI\ runner has no key.
    TEXT
}

wcl check validates it and says nothing else. wcl parse prints what the document became, which is the interesting half — seven of the values on that page are not the text you typed:

text
$ wcl check release.wcl
OK

$ wcl parse release.wcl
...
version = "2.4.0"
build = 500u32
channel = :stable
ratio = 0.75
timeout = 90000000000
shipping = 12000
banner = "wcl 2.4.0 (build 500) on :stable"
artifact wcl_linux {
  size = 18874368
  checksum = ascii"9f2c41ab"
  signed = true
}
artifact wcl_windows {
  size = 22020096
  checksum = ascii"c30dd5e7"
  signed = false
  notes = "Unsigned: the \\CI\\ runner has no key.\n"
}

0x1F4u32 became 500u32, because a radix is how you write a number and not part of it. 90s became 90000000000 and 12kg became 12000, because a unit multiplies. $"…" became one finished string. The raw heredoc kept both its backslashes. The elided ... is the built-in schema library, which wcl parse prints above your own declarations; the rest of this chapter is those transformations, one at a time.

§ 2Numbers

WCL has fourteen numeric types: signed and unsigned integers at six widths, and floats at two. The type is part of the value, not a hint — 500u32 and 500 are different values that happen to compare equal.

WidthSignedUnsignedFloat
8-biti8u8
16-biti16u16
32-biti32u32f32
64-biti64u64f64
128-biti128u128
pointer widthisizeusize

A literal with no suffix is i64 if it has no decimal point and f64 if it has one. Those two are also the only types wcl parse prints bare; every other numeric value keeps its suffix in the dump so the output re-parses as the same value. isize and usize are the pointer width of the machine wcl runs on — 64 bits on an ordinary desktop or server, 32 on a smaller target — so avoid them for anything a second machine has to agree with.

§ 2.1Writing a number

Four radixes, a digit separator, and scientific notation:

wcl
a = 42            # i64, the default
b = 200u8         # unsigned 8-bit
c = 1_000_000     # underscores group digits and are ignored
d = 0xDEAD_BEEF   # hex (0X works too)
e = 0b1010_1100   # binary
f = 0o755         # octal
g = 3.14          # f64, the default
h = 1.5f32        # narrowed to 32-bit
i = 1.5e-3        # 0.0015
j = -128i8        # the sign is part of the literal

§ 2.2The suffix slot

A number has exactly one suffix slot, and two different things compete for it. If the suffix names one of the fourteen numeric types, it pins the type. If it names anything else at all, it is a literal unit and the number is multiplied — see Literal units.

wcl
a = 200u8    # width suffix: the value is 200, typed u8
b = 200MiB   # literal unit:  the value is 209715200, typed by the field

There is no third case. An unknown suffix is never an error at the literal — it becomes a unit name and the error, if there is one, arrives when the value meets its declared type. That is why 1e6 does not mean a million: e6 is not a numeric type, so it is a unit called e6, and 'e6' is not a unit of type 'f64' is what you get. Write 1.0e6.

§ 3Numeric promotion

Arithmetic and comparison accept mixed numeric types. When the two operands share a type the operation runs in that type; when they do not, both are promoted to a common one first. The ladder has exactly two rungs: if either side is a float, both become f64; otherwise both become i128.

wcl
a = 1 + 2.0        # 3.0    — i64 and f64 meet at f64
b = 3.0 * 2u8      # 6.0    — same rung
c = 1u32 == 1i64   # true   — comparison promotes as well
d = 2u8 < 300u32   # true

Promotion is also what decides whether an operation overflows, and the order matters more than it looks. Overflow is judged in the type the operands share after promotion, so widening one side can make the same sum legal:

wcl
a = 127i8 + 1i8    # error — both are i8, and 128 is not
b = 127i8 + 1      # 128i128 — mixed, so both promote first

Write both suffixes when you want the narrow type enforced. Write one when you want the arithmetic to be about the number.

An integer operation with no representable answer is an evaluation error, never a wrapped or truncated value. Floats keep IEEE semantics instead and produce inf or NaN. All four of these live in one file:

arith.wclwcl
@document
type Cfg {
  a: i64
  b: i8
  c: f64
  d: f64
}

a = 4 / 0
b = 127i8 + 1i8
c = 4.0 / 0.0
d = 0.0 / 0.0
text
$ wcl get arith.wcl a
wcl::eval::arithmetic

  × operator '/' cannot divide by zero

$ wcl get arith.wcl b
wcl::eval::arithmetic

  × operator '+' cannot represent the result in i8 (overflow)

$ wcl get arith.wcl c
inf

$ wcl get arith.wcl d
NaN

Both of those errors are ordinary evaluation errors, so try … catch recovers from them like any other. See Control flow, and Expressions and operators for the operator set itself.

§ 4Literal units

A numeric literal may carry a unit: 18MiB, 90s, 3km, 12kg. The unit is written attached to the magnitude, in the same lexical slot as a width suffix — 18MiB, never 18 MiB. At evaluation the magnitude is multiplied by the unit's factor and the product is stored in the type's base unit, so nothing downstream has to remember which unit you wrote.

Units are type-scoped. A unit literal does not know what it means on its own; it resolves against the declared type of the field or parameter it lands in, using the @unit(name, factor) decorators on that type. The same 5s is five billion nanoseconds against std.Duration and an error against std.ByteSize.

§ 4.1The three built-in unit types

Three unit types are always in scope with no import — the language injects them into every document:

TypeBase unitUnits it declares
std.ByteSizebyteB, then KiB MiB GiB TiB PiB (×1024ⁿ) and kB MB GB TB (×1000ⁿ)
std.Distancemillimetremm, cm, dm, m, km
std.Durationnanosecondns, us, ms, s, min, h, d
units.wclwcl
@document
type Cfg {
  buffer:  std.ByteSize
  radius:  std.Distance
  timeout: std.Duration
  sizes:   list<std.ByteSize>
  half:    std.ByteSize
  label:   utf8
}

buffer  = 4MiB
radius  = 3km
timeout = 30s
sizes   = [256KiB, 1MiB]
half    = 1.5MiB
label   = format_unit(buffer, "std.ByteSize", "KiB")
text
$ wcl get units.wcl buffer
4194304
$ wcl get units.wcl radius
3000000
$ wcl get units.wcl timeout
30000000000
$ wcl get units.wcl sizes
[262144, 1048576]
$ wcl get units.wcl half
1572864
$ wcl get units.wcl label
"4096 KiB"

Three things to read off that. A list<T> resolves element by element, so [256KiB, 1MiB] needs no common unit. A fractional magnitude is allowed as long as the product lands whole — 1.5MiB is 1572864, but 1.5B is unit 'B' produces a fractional value for integer type 'std.ByteSize'. And format_unit goes back the other way, in whichever unit you ask for rather than the one that was written: it reads the factor off the named type rather than hard-coding one, so it stays correct if the factor ever changes. Builtins documents it and its lower-level twin format_unit_value.

§ 4.2Declaring your own units

Nothing about the mechanism is special to std.*. Hang @unit decorators on any numeric type alias and that alias gains those units. The factor is how many base units make one of that unit, and it is an ordinary expression:

wcl
@unit("g", 1)
@unit("kg", 1000)
@unit("t", 1000 * 1000)
type Mass = i64

That is the whole of std.ByteSize too — it is an i64 alias with ten @unit decorators on it. Decorators covers the repeated-decorator form, and Types covers aliases.

§ 4.3When a unit does not resolve

A unit literal that reaches a type declaring no such unit is an error, and so is one that reaches a plain numeric type with no units at all. Change buffer in units.wcl above to 5km and read it back:

text
$ wcl get units.wcl buffer
wcl::eval::unit_no_match

  × 'km' is not a unit of type 'std.ByteSize'
  help: declare it with `@unit("km", <factor>)` on the type alias, or use one
        of its declared units

A unit resolves when it is read, not when it is checked

Unit resolution happens during evaluation, and fields evaluate lazily — so wcl check prints OK on the file above. wcl get, wcl eval and wcl parse all force the field and report it. This is the general shape of laziness rather than anything about units; How a document evaluates covers which errors surface when.

§ 5Booleans

bool has exactly two values, true and false. Both are reserved words, so nothing in a document can be named either one.

flags.wclwcl
@document
type Cfg {
  signed:    bool
  published: bool
  shippable: bool
  visible:   bool
}

signed    = true
published = false

shippable = signed && !published
visible   = published || signed == false

Every comparison produces one, and &&, || and ! consume them. There is no truthiness in either direction: true + 1 is operator '+' is not defined for bool and i64, and 1 && true is refused by && in turn. A number is never a condition and a condition is never a number. Expressions and operators has the precedence table.

§ 6Identifiers

An identifier is a bare name. The lexical rule is one rule everywhere — fields, types, block kinds, variants, symbols, let bindings, imported items: start with an ASCII letter or an underscore, continue with letters, digits or underscores. No Unicode, no dashes, no spaces.

NameLegalWhy
name, my_fieldyesLetters, and an underscore joining them
_internalyesAn underscore opens a name as happily as a letter does
v2, HTTPStatusyesAfter the first character, a digit or a capital is just a character
2nd_attemptnoThe first character was a digit
kebab-casenoA dash is not a name character — except in a block label, below

identifier is also a type. Declaring a field as identifier rather than utf8 says the value is a name something else may point at, not free text — which is what the reference checking in Schemas hangs off. A quoted string in an identifier slot coerces to the identifier it names, so owner = platform and owner = "platform" are the same value; both print as bare platform.

§ 6.1Where a label is looser

Block labels are the one place a bare name may carry - and / connectors, so kebab-case names and path-like names need no quoting. The connector sits directly between name parts, with no spaces around it:

routes.wclwcl
@block("route")
type Route { @inline(0) name: identifier }

@document
type Cfg { @children("route") routes: list<Route> }

route health-check        # kebab-case, no quotes needed
route api/v1/users        # path-like, likewise
route "health-check"      # quoting still works, and addresses the same

§ 6.2Reserved words

Exactly six words are reserved by the lexer. They are values and control-flow keywords, and they cannot be used as a name in any position:

ReservedBecause it is
true, falseA boolean literal
noneThe absent-value literal
if, else, matchControl flow

That is the whole list. type, interface, union, symbol_set, let, fn, import, use, namespace, connection, extends and as are recognised only in declaration position, so type = 1 is a legal field. It reads badly; the language will not stop you.

Conventions, not rules

The standard library uses snake_case for fields, let bindings, block kinds and symbols, and PascalCase for types, interfaces, unions, variants and symbol sets. Nothing enforces this. Pick a style and hold it.

§ 7Symbols

A symbol is written :name — a colon with no space, then an identifier. It is a name used as a value: a tag, an enum-like choice, a mode. Where a string says "here is some text", a symbol says "here is one of a set of things".

wcl
shade   = :amber
edge    = :uses
channel = :stable

A field declared symbol accepts any symbol at all; nothing validates which. That is the right type for an open-ended tag.

§ 7.1Symbol sets

When the vocabulary is closed, declare it. A symbol_set is a top-level item naming the members, and a field declared with the set's name accepts only those:

channel.wclwcl
symbol_set Channel { alpha  beta  stable }

@document
type Cfg {
  channel: Channel
  tag:     symbol
}

channel = :stable
tag     = :anything_goes
text
$ wcl check channel.wcl
OK

$ wcl check channel.wcl                     # after channel = :qa
wcl::eval::schema_violation

  × field 'channel' declared as symbol_set 'Channel' but ':qa' is not one of
  │ its members

channel.wcl: 1 schema violation

A symbol_set is one of the thirteen item forms from chapter 1, and it is the language's enum. Types covers it beside the other declaration forms.

§ 8Strings

A double-quoted literal is a string, and it is utf8 unless you say otherwise. A string may not contain a bare newline — for multi-line text use a heredoc, below.

§ 8.1Four encodings

The encoding is part of the type, not a rendering choice. A prefix on the literal picks it:

wcl
name  = "hello"          # utf8, the default
label = utf8"hello"      # the same, written explicitly
tag   = ascii"id-007"
wide  = utf16"hello"
quad  = utf32"hello"
TypeHolds
utf8Variable-width Unicode, 1–4 bytes per character. The default.
ascii7-bit text, one byte per character. A non-ASCII character in the literal is a lex error.
utf16UTF-16 code units, two bytes per BMP code point.
utf32Code points, four bytes each.

The four do not mix

A utf8 field will not take an ascii literal, and an ascii field will not take a plain one: field 'a' declared as utf8 but value is ascii. Two strings that print the same are still two types. Convert at the host layer, or declare the field as the encoding you are going to write.

§ 8.2Escapes

Inside a plain double-quoted string, five escapes are recognised, and only five: \\, \", \n, \t and \r. (An interpolated literal adds a sixth, \$, which the next section covers.) \u{263A} is not among them — it fails with invalid escape '\u' — and neither is any other letter you might expect. Write the character itself; the file is UTF-8.

wcl
greeting = "Hello,\nworld!"
quoted   = "She said \"hi\"."
smiley   = ""

For backslash-heavy text — regexes, LaTeX, Windows paths — reach for a raw heredoc instead of escaping every one.

§ 8.3Interpolation

Interpolation is opt-in, and the opt-in is a $ before the opening quote. Inside a $"…" literal, a ${ … } slot evaluates any expression and splices the result in. Without the $, the same slot is ordinary text and nothing evaluates:

wcl
plain  = "cost: ${1 + 1}"    # "cost: ${1 + 1}"
live   = $"cost: ${1 + 1}"   # "cost: 2"

banner = $"wcl ${version} (build ${build}) on ${channel}"

That banner is the one from release.wcl, and it evaluates to "wcl 2.4.0 (build 500) on :stable". Note what each slot contributed: the u32 lost its suffix, and the symbol kept its colon. A slot renders the value the way a person reads it, which is not the way wcl parse dumps it — format(…) in Builtins is the same rendering, callable.

All four encodings take the $ prefix ($ascii"…", $utf16"…"). Inside an interpolated literal \$ escapes a literal dollar; inside a plain one \$ is an invalid escape, because there is nothing there to escape. A slot must stay on one line, and may not contain a heredoc.

§ 8.4Heredocs

<<TAG opens a heredoc. The body starts on the next line and runs to the first line whose trimmed content is exactly TAG. Escapes are interpreted, exactly as in a quoted string, and the body always ends with a newline.

Common leading whitespace is stripped, so a heredoc indents with the block it sits in. Read that rule precisely: the amount stripped is the smallest indent of any non-blank body line. The closing tag's own indentation is not what decides it, and blank lines do not count:

here.wclwcl
@document
type Cfg { note: utf8 }

note = <<END
  First line.
    Indented two more.
  Second line.
  END
text
$ wcl get here.wcl note
"First line.\n  Indented two more.\nSecond line.\n"

Two spaces came off every line, because two is the least any line had. The deeper line kept its extra two. A $<<TAG opener adds interpolation, and an encoding prefix works here too (ascii<<TAG).

§ 8.5Raw heredocs

Quote the tag — <<'TAG' — and the body is taken verbatim. No escape decoding, no ${ … } slots, nothing between what you typed and the value. Indentation is still stripped, so it still sits inside a block comfortably.

raw.wclwcl
@document
type Cfg { regex: utf8 }

regex = <<'RAW'
  \d{3}-\d{4}  ${not_a_slot}
  RAW
text
$ wcl get raw.wcl regex
"\\d{3}-\\d{4}  ${not_a_slot}\n"

Written as a plain <<RAW that same body is a parse error — invalid escape '\d' — which is the practical reason the form exists. Use <<'TAG' for regexes, LaTeX, shell snippets, Windows paths, and for the code samples in a document like this one. Note the closing tag is the bare word: <<'RAW' opens, RAW closes.

§ 9none

none is the absence of a value. A field may hold it only if its declared type is optional — utf8?, Limits?, list<T>?. Against a required type it is a schema violation like any other wrong value: field 'region' declared as utf8 but value is none.

none.wclwcl
@document
type Cfg {
  region:   utf8?
  fallback: utf8
  isnone:   bool
}

region   = none
fallback = region ?? "us-east-1"
isnone   = region == none
text
$ wcl get none.wcl region
none
$ wcl get none.wcl fallback
"us-east-1"
$ wcl get none.wcl isnone
true

?? is the none-coalescing operator: it evaluates its left side, and only if that is none does it evaluate its right. It short-circuits, so the fallback costs nothing when it is not needed.

Written none and absent are not the same thing

region = none is a field that holds none. Leaving the line out entirely is a field that does not exist: wcl check still passes, because the type is optional, but wcl get answers no such path: region rather than none. Both are legal; only the first is addressable.

§ 10Three ways to write a name

Strings, identifiers and symbols overlap where it matters most — all three can carry the word web. The separate reference pages this book replaced described them one at a time, so the choice never came up. It is the choice you will make most often, so here it is in one place:

names.wclwcl
symbol_set Channel { alpha  beta  stable }

@block("target")
type Target {
  @inline(0) name: identifier
  title:   utf8
  owner:   identifier
  channel: Channel
  tag:     symbol
}

@document
type Cfg {
  @children("target") targets: list<Target>
}

target web {
  title   = "Web front end"
  owner   = platform
  channel = :stable
  tag     = :anything_goes
}
text
$ wcl get names.wcl targets.web.title
"Web front end"
$ wcl get names.wcl targets.web.owner
platform
$ wcl get names.wcl targets.web.channel
:stable
$ wcl get names.wcl targets.web.tag
:anything_goes
WrittenDeclared asWhat checks itReach for it when
"web"utf8Nothing — any text at allA person reads the value
webidentifierThe reference rules of SchemasSomething else in the document points at it
:websymbolNothing — any symbol at allIt is a tag, and the vocabulary is open
:weba symbol_setMembership of the setIt is one of a closed set of choices

Change one line at a time and watch which ones the tool catches. tag = "stable" fails — field 'tag' declared as symbol but value is utf8 — because a string is not a symbol however much it looks like one. channel = :qa fails on membership. But owner = "platform" passes: a string in an identifier slot coerces, so quoting a reference is a style choice rather than a mistake. And title = platform passes wcl check and then fails on wcl get with unresolved reference 'platform' — a bare name in an expression is a lookup, and the lookup is lazy.

The rule underneath: utf8 is text, identifier is a name in this document's namespace, and symbol is a name from a vocabulary. Only an identifier is ever resolved, and only a symbol_set member is ever checked against a list.

§ 11Every primitive at a glance

ValueWrittenDeclared aswcl get prints
Integer42, 0x1F4u32, 1_000i8u128, isize, usize42, 500u32
Float0.75, 1.5e-3, 1.5f32f32, f640.75, 1.5f32
Unit literal18MiB, 90s, 12kgany numeric alias carrying @unit18874368 — the base-unit product
Booleantrue, falsebooltrue
String"2.4.0", ascii"9f2c41ab"utf8, ascii, utf16, utf32"2.4.0", ascii"9f2c41ab"
Identifierplatformidentifierplatform
Symbol:stablesymbol, or a symbol_set:stable
Absencenoneany optional type, T?none

Read the last two columns together. What wcl get prints is always legal WCL for the same value — suffix, prefix and colon included — so a dump re-parses as what it dumped. The one row where the text does not survive is the unit literal, and that is the point of it: 18MiB was never a value, only a way of writing 18874368. Printing a value is the quickest way to find out what it really is.

§ 12Where to go next