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

Pkl modules

Requires the renderer_pkl_schema Cargo feature.

With our PklSchemaRenderer, you can generate Pkl modules for all types that implement Schematic. Each struct and enum becomes its own module, with its Rust types converted to Pkl types, so that a Pkl config can amend it and be type checked by Pkl itself.

To utilize, instantiate a generator, add types to render, and generate a module for every type into a directory. Modules import each other by relative path, so they must stay together.

#![allow(unused)]
fn main() {
use schematic::schema::{SchemaGenerator, PklSchemaRenderer};

let mut generator = SchemaGenerator::default();
generator.add::<ServerConfig>();

PklSchemaRenderer::default().generate_all(&generator, output_dir.join("pkl"))?;
}

Each module is named after its type with a .pkl extension, so the above writes ServerConfig.pkl, and a module for every type it nests. A config then amends the module of its root type.

amends "pkl/ServerConfig.pkl"

port = 3000
timeout = 30.s
tags { "web"; "api" }

To scaffold that config, render a Pkl template with an amends clause in its header.

Like a JSON schema, rendering through SchemaGenerator::generate() produces a single document, which is the module of the last type to be added to SchemaGenerator. The modules it imports are not written, so prefer generate_all().

Module structure

A struct becomes a module that declares each of its fields as a property, with its doc comment and deprecation carried over. Fields are rendered in alphabetical order, and skipped fields are omitted.

/// Configures the HTTP server.
module ServerConfig

import "LogLevel.pkl"
import "TlsConfig.pkl"

/// The level to log at.
level: LogLevel.LogLevel

/// The port to listen on.
port: UInt16 = 8080

/// TLS settings, when serving over HTTPS.
tls: TlsConfig?

A module is a type, but it can’t be a union or a string, so every other type, such as an enum, becomes a module that declares a type alias of the same name. It is referenced as Name.Name. The default variant of an enum is marked with *, which makes it the default of any property of that type that isn’t nullable.

/// The level to log at.
typealias LogLevel = *"info" | "debug" | "error"

A struct without a name of its own, such as a variant of a tagged enum, becomes a class in the module it’s found in, named after its position, such as ActionRun for the Run variant of Action. A class is referenced from other modules as Module.Class.

typealias Action = "Stop" | ActionRun

class ActionRun {
  Run: String
}

Inheritance

A struct that flattens another struct with #[serde(flatten)] includes all of its fields, which is what extending a module does. Its module extends the module of the flattened struct, which is then declared open, as Pkl requires to extend it.

open module BaseConfig

shared: String = "shared"
module ServerConfig

extends "BaseConfig.pkl"

port: UInt16 = 8080

When the struct declares nothing but the flattened field, it has the same shape as the struct it flattens, so its module amends that module instead. Pkl can’t use a module that amends another as a type, so this only happens when no other type refers to the struct.

A module can only extend one other, so any further flattened structs have their fields declared directly.

Other settings

A flattened map collects every setting a struct doesn’t declare, such as the configuration of a plugin. A typed Pkl object only accepts the properties its type declares, so no type can hold such a struct. Wherever it’s used, it’s typed as Dynamic instead, which accepts any property.

/// Configures a plugin.
plugin: Dynamic?
plugin {
  version = "1.0"
  manager = "pnpm"
}

Its module is declared open, with a comment naming the map. A config can’t declare new properties while amending a module, but it can while extending one, so a config of that struct extends its module.

extends "pkl/PluginsConfig.pkl"

registry = "https://registry.example.com"
node { version = "20" }

Types

Rust types are converted to their closest Pkl types, and constraints, such as a minimum or a pattern, become type constraints.

RustPkl
boolBoolean
i8, i16, i32Int8, Int16, Int32
i64, i128, isizeInt
u8, u16, u32UInt8, UInt16, UInt32
u64, u128, usizeUInt
f32, f64Float
charChar
String, PathBuf, Url, …String
Vec<T>Listing<T>
HashSet<T>, BTreeSet<T>Listing<T>(isDistinct)
[T; N]Listing<T>(length == N)
HashMap<K, V>, BTreeMap<K, V>Mapping<K, V>
(A, B)Pair<A, B>
(A, B, C, ...)Listing<A|B|C>(length == N)
Option<T>T?
std::time::DurationDuration
serde_json::Value, …Any
Unit-only enum"a"|"b"|"c"

Pair and Duration are decoded by the Pkl format into the same shapes as a Rust tuple and Duration, so timeout = 30.s loads as expected. Pkl’s own JSON and YAML renderers can’t output them without a converter, however.

Pkl has two types of each collection: an amendable one, Listing and Mapping, and an eager one, List, Set, and Map, which can be concatenated. The Pkl format decodes them all the same, so a list or a map accepts either. The amendable type is marked as the default, so that a config can amend it, as in tags { "web" }, as well as assign an eager one, as in tags = List("web") + shared.

A union with a struct among its variants, such as an untagged enum, has no default to amend, and Pkl can’t tell which struct a new {} creates. Create the struct by its module instead.

import "pkl/TaskDependencyConfig.pkl"

deps = List("build", new TaskDependencyConfig { target = "~:lint"; optional = true })

Aliases

Pkl outputs every property, and serde rejects a setting that’s given under both its name and an alias, so an alias is declared hidden, which Pkl doesn’t output. The setting falls back to its alias, so setting either one outputs the setting under its name.

/// The default value of the variable.
default: Boolean? = defaultValue

/// An alias of `default`.
hidden defaultValue: Boolean?

A setting that isn’t nullable would lose the default of its type by falling back, so only a nullable setting declares its aliases.

A few things can’t be expressed in Pkl, and are rendered as close as possible:

  • A setting named output can’t be declared by a module, as every module reserves it. It is left out, with a comment in its place.
  • Pkl rejects a type alias that refers to itself, so a recursive enum refers to itself as Any. A recursive struct refers to its own module, which is fine.
  • A tuple of more than two values can only be typed as a list of each of its types, so the position of each type isn’t checked.

Options

Custom options can be passed to the renderer using PklSchemaOptions.

#![allow(unused)]
fn main() {
use schematic::schema::PklSchemaOptions;

PklSchemaRenderer::new(PklSchemaOptions {
	// ...
	..PklSchemaOptions::default()
});
}

Indentation

The indentation of class properties can be customized using the indent_char option. By default this is 2 spaces.

#![allow(unused)]
fn main() {
PklSchemaOptions {
	// ...
	indent_char: "\t".into(),
}
}

Required fields

By default, struct fields are rendered as they are declared. A field that isn’t optional has no default, so a config must set it, and a field with a default value renders it as the property’s default.

Pkl outputs every property of a module, including the defaults a config didn’t set. A config is only one layer of many however, so a default it outputs overrides the value of any layer beneath it, such as a config it extends. Disable the mark_struct_fields_required option to render every property of a struct’s module as nullable, without a default, so that a config only outputs the settings it sets, and everything else falls back to the defaults of the Rust types.

#![allow(unused)]
fn main() {
PklSchemaOptions {
	// ...
	mark_struct_fields_required: false,
}
}
// Enabled
port: UInt16 = 8080
host: String

// Disabled
port: UInt16?
host: String?

This only applies to structs that are loaded as partials, which is what makes their settings optional. A struct that’s loaded in full, such as the value of a setting that isn’t nested, or a type that doesn’t derive Config, can’t take null for a field that isn’t an Option, so it’s always rendered as declared. Classes, such as the variants of an enum, are too, as their value is only valid in full.

Rendering partial types has the same effect, as every field of a partial is optional, but names every module after its partial type.