Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Schemas

Schemas define the expected shape of blocks — their fields, types, and constraints. When a schema exists with the same name as a block type, WCL automatically validates every block of that type against it.

Syntax

schema "service" {
    port:     int    @required
    region:   string @required
    env:      string @default("production")
    tags:     list   @optional
    replicas: int    @validate(min = 1, max = 100)
}

A schema body is a list of field declarations. Each field has a name, a type, and zero or more decorators.

Matching Blocks to Schemas

A schema named "service" is automatically applied to every service block in the document:

service "api" {
    port    = 8080
    region  = "us-east-1"
    env     = "staging"
    replicas = 3
}

Matching is done by block type name. There is no explicit @schema annotation needed on the block.

Field Types

The following primitive types are available for schema fields:

TypeDescription
stringA string value
intAn integer value
floatA floating-point value
boolA boolean value
listA list of values
mapA key-value map
anyAccepts any value type
symbolA symbol literal (e.g. :GET)

Field Decorators

@required

Fields are required by default. You may add @required explicitly for clarity:

schema "database" {
    host: string @required
    port: int    @required
}

@optional

Marks a field as not required. If the field is absent from the block, no error is raised:

schema "service" {
    debug_port: int @optional
}

@default(value)

Provides a default value used when the field is absent. Implies @optional:

schema "service" {
    env:      string @default("production")
    replicas: int    @default(1)
}

@validate(…)

Attaches constraints to a field’s value:

schema "service" {
    port:     int    @validate(min = 1, max = 65535)
    env:      string @validate(one_of = ["development", "staging", "production"])
    name:     string @validate(pattern = "^[a-z][a-z0-9-]*$")
    replicas: int    @validate(min = 1, max = 100, custom_msg = "replicas must be between 1 and 100")
}

Available constraint arguments:

ArgumentApplies toDescription
minint, floatMinimum value (inclusive)
maxint, floatMaximum value (inclusive)
patternstringRegex pattern the value must match
one_ofstring, intValue must be one of the listed options
custom_msganyCustom error message on violation

Cross-References with @ref

Use @ref("schema_name") on an identifier field to require that the referenced value points to a valid block of the named type:

schema "deployment" {
    service_id: string @ref("service")
    region_id:  string @ref("region")
}

When a deployment block has service_id = "api", WCL verifies that a service "api" block exists in the document.

ID Naming Conventions with @id_pattern

Use @id_pattern("glob") on a schema’s identifier field to enforce naming conventions on block IDs:

schema "service" @id_pattern("svc-*") {
    port: int
}

Any service block whose ID does not match the glob svc-* will produce a validation error.

Nested Schema References with ref()

Use ref("other_schema") as a field type to require that the field’s value conforms to another schema:

schema "address" {
    street: string
    city:   string
    zip:    string @validate(pattern = "^[0-9]{5}$")
}

schema "contact" {
    name:    string
    address: ref("address")
}

Open vs Closed Schemas

By default schemas are closed: any attribute present in a block but not declared in the schema produces an error (error code E072).

Add the @open decorator to allow extra attributes:

schema "service" @open {
    port:   int
    region: string
}

An open schema validates all declared fields but silently permits additional attributes not listed in the schema.

Validation Timing

Schema validation runs at phase 9 of the WCL pipeline, after:

  1. Import resolution
  2. Macro expansion
  3. Control flow expansion (for/if)
  4. Partial merging
  5. Scope construction and evaluation

This means schema validation sees the fully resolved document. Computed values, macro-generated blocks, and merged partials are all validated.

Accumulative Error Reporting

Schema validation is accumulative. All violations across all blocks are collected before reporting, so you see every error in a single pass rather than stopping at the first failure.

Composition: ref() and Partials

WCL schemas do not support inheritance. Instead, use two composition mechanisms:

  • ref("schema") — reference another schema as a field type.
  • Partials — share common attribute groups across blocks and merge them before validation runs.
schema "base_service" {
    port:   int
    region: string
}

schema "web_service" {
    base:   ref("base_service")
    domain: string
    tls:    bool @default(true)
}

Full Example

schema "service" @id_pattern("svc-*") {
    port:     int    @required @validate(min = 1, max = 65535)
    region:   string @required @validate(one_of = ["us-east-1", "eu-west-1", "ap-south-1"])
    env:      string @default("production") @validate(one_of = ["development", "staging", "production"])
    tags:     list   @optional
    replicas: int    @default(1) @validate(min = 1, max = 100)
}

service "svc-api" {
    port     = 8080
    region   = "us-east-1"
    env      = "staging"
    tags     = ["web", "critical"]
    replicas = 3
}

service "svc-worker" {
    port   = 9090
    region = "eu-west-1"
}

The svc-worker block inherits env = "production" and replicas = 1 from the schema defaults.

Per-Child Cardinality with @child

Use @child("kind", min=N, max=N) to enforce how many children of a given kind a block must/may have:

schema "server" {
    @child("endpoint", min=1, max=10)
    @child("config", max=1)
    port: int
    host: string
}
  • min — error if fewer children of that kind exist (E097)
  • max — error if more children of that kind exist (E098)
  • @child("kind") with no min/max just adds the kind to the allowed children set (like @children)
  • @child entries merge into the @children constraint automatically

Self-Nesting with max_depth

Use @child("kind", max_depth=N) to allow a block to contain itself, up to a depth limit:

schema "menu" {
    @child("menu", max_depth=3)
    label: string
}

menu top {
    label = "File"
    menu sub {
        label = "Open"
        menu deep {
            label = "Recent"  // depth 3 — allowed
            // menu too-deep { ... }  // ERROR E099: exceeds max depth
        }
    }
}

Union Field Types

Use union(t1, t2, ...) to declare that a field accepts any of the listed types:

schema "config" {
    value: union(string, int, bool)
}

config a { value = "hello" }
config b { value = 42 }
config c { value = true }

Tagged Variant Schemas

Use @tagged("field") and variant "value" { ... } to define schemas where required fields depend on a discriminator value:

@tagged("style")
schema "api" {
    style: string
    version: string @optional

    @children(["resource"])
    variant "rest" {
        base_path: string
    }

    @children(["gql_query", "gql_mutation"])
    variant "graphql" {
        schema_path: string @optional
    }
}

api rest-api {
    style = "rest"
    base_path = "/api/v1"
}

api gql-api {
    style = "graphql"
}
  • Common fields (outside variants) apply to all blocks
  • When the tag field matches a variant, that variant’s fields are also validated
  • When no variant matches, only common fields are validated
  • Variant @children/@child decorators override the base schema’s containment for that variant
  • Variant fields are accepted by closed schemas even when not in the active variant

Symbols

Symbol literals are lightweight, identifier-like values prefixed with a colon. They are useful when a field represents a fixed set of named options rather than arbitrary strings.

Symbol Literals

A symbol literal is written as a colon followed by an identifier:

endpoint list_users {
    method = :GET
    path   = "/users"
}

Symbol values are distinct from strings. :GET is not the same as "GET".

Symbol Sets

A symbol_set declaration defines a named group of valid symbols:

symbol_set http_method {
    :GET
    :POST
    :PUT
    :PATCH
    :DELETE
    :HEAD
    :OPTIONS
}

Value Mappings

Each member of a symbol set can optionally map to a string value using =. This controls how the symbol serializes to JSON:

symbol_set curl_option {
    :unix_socket = "unix-socket"
    :compressed  = "compressed"
    :verbose     = "verbose"
}

Without an explicit mapping, a symbol serializes to its name as a string (e.g. :GET becomes "GET" in JSON output).

Using @symbol_set in Schemas

Use the symbol type and the @symbol_set decorator to constrain a field to members of a declared set:

schema "endpoint" {
    method: symbol @symbol_set("http_method")
    path:   string
}

If a block provides a symbol value that is not a member of the referenced set, error E100 is raised. If the named set does not exist, error E101 is raised.

The Special “all” Set

Use the set name "all" to accept any symbol value without restricting to a specific set:

schema "tag" {
    kind: symbol @symbol_set("all")
}

tag important {
    kind = :priority    // any symbol is accepted
}

This is useful when you want the symbol type for its semantics (not a free-form string) but do not want to enumerate every valid value.

JSON Serialization

Symbols serialize to JSON as strings:

  • A symbol with no value mapping serializes to its identifier name: :GET becomes "GET".
  • A symbol with a value mapping serializes to the mapped string: :unix_socket = "unix-socket" becomes "unix-socket".
symbol_set http_method { :GET :POST }

endpoint example {
    method = :GET
}

// JSON output:
// { "endpoint": { "example": { "method": "GET" } } }

Symbol Error Codes

CodeMeaning
E100Symbol value not in declared symbol_set
E101Referenced symbol_set does not exist
E102Duplicate symbol_set name
E103Duplicate symbol within a symbol_set

Error Codes

CodeMeaning
E001Duplicate schema name
E030Duplicate block ID
E070Missing required field
E071Type mismatch
E072Unknown attribute in closed schema
E073min/max constraint violation
E074Pattern constraint violation
E075one_of constraint violation
E076@ref target not found
E077@id_pattern mismatch
E080Validation block failure
E092Inline columns defined when schema is applied
E095Child not allowed by parent’s @children list
E096Item not allowed by its own @parent list
E097Child count below @child minimum
E098Child count above @child maximum
E099Self-nesting exceeds @child max_depth
E100Symbol value not in declared symbol_set
E101Referenced symbol_set does not exist
E102Duplicate symbol_set name
E103Duplicate symbol within a symbol_set

Block & Table Containment

Use @children and @parent decorators on schemas to constrain which blocks and tables can nest inside which others.

@children — restrict what a block may contain

@children(["endpoint", "table:user_row"])
schema "service" {
    name: string
}

service "api" {
    name = "my api"
    endpoint health { path = "/health" }     // allowed
    table users : user_row { | "Alice" | }   // allowed
    // logger { level = "info" }             // ERROR E095
}

Use @children([]) to create a leaf block that cannot contain any children.

@parent — restrict where a block may appear

@parent(["service", "_root"])
schema "endpoint" {
    path: string
}

The special name "_root" refers to the document’s top level. Use a schema named "_root" with @children to constrain what appears at the top level:

@children(["service", "config"])
schema "_root" {}

Table containment

Define virtual schemas named "table" or "table:X" to constrain table placement:

@parent(["data"])
schema "table:user_row" {}

@parent(["_root"])
schema "table" {}

See Built-in Decorators for full details.

Applying Schemas to Tables

You can apply a schema to a table using the colon syntax or the @schema decorator:

schema "user_row" {
    name : string
    age  : int
}

# Colon syntax
table users : user_row {
    | "Alice" | 30 |
}

# Decorator syntax
@schema("user_row")
table contacts {
    | "Bob" | 25 |
}

# With CSV import
table imported : user_row = import_table("users.csv")

When a schema is applied, inline column declarations are not allowed (E092).