Writing a Template

In parts one and two, you saw that Pkl provides validation of our configurations. It checks syntax, types and constraints. As you saw in the acmecicd example here, the template can provide informative error messages when an amending module violates a type constraint.

In this final part, you will see some of Pkl’s techniques that are particularly relevant for writing a template.

Basic types

Pkl always checks the syntax of its input. As it evaluates your configuration, it also checks types. You’ve seen objects, listings, and mappings already. These provide ways to write structured configuration. Before you can write types for them, you need to know how to write the types for the simplest (unstructured) values.

These are all Pkl’s basic types:

pklTutorialPart3.pkl
name: String = "Writing a Template"

part: Int = 3

hasExercises: Boolean = true

amountLearned: Float = 13.37

duration: Duration = 30.min

bandwidthRequirementPerSecond: DataSize = 50.mb

In the above, you’ve explicitly annotated the code with type signatures. The default output of Pkl is actually pcf, which is a subset of Pkl. Since pcf does not have type signatures, running Pkl on this example removes them.

$ pkl eval pklTutorialPart3.pkl
name = "Writing a Template"
part = 3
hasExercises = true
amountLearned = 13.37
duration = 30.min
bandwidthRequirementPerSecond = 50.mb

Note how Duration and DataSize help you prevent unit errors in these common (for configuration) domains.

Typed objects, properties and amending

Having a notation for basic types, you can now write typed objects.

simpleClass.pkl
class Language { (1)
  name: String
}

bestForConfig: Language = new { (2)
  name = "Pkl"
}
1 A class definition.
2 A property definition, using the Language class.

Although not required (or enforced), it’s customary to name properties starting with a lower-case letter. Class names, by that same convention, start with an upper-case letter.

You can type objects with classes. In this example, you define a class called Language. You can now be certain that every instance of Language has a property name with type String.

Types and values are different things in Pkl. Pkl does not render types in its output,[1] so when you run Pkl on this, you don’t see the class definition at all.

$ pkl eval simpleClass.pkl
bestForConfig {
  name = "Pkl"
}

Did you notice that the output doesn’t just omit the type signature, but also the = new? We will discuss this further in the next section.

When your configuration describes a few different parts like this, you can define one instance and amend it for every other instance.

For example:

pklTutorialParts.pkl
class TutorialPart {
  name: String

  part: Int

  hasExercises: Boolean

  amountLearned: Float

  duration: Duration

  bandwidthRequirementPerSecond: DataSize
}

pklTutorialPart1: TutorialPart = new {
  name = "Basic Configuration"
  part = 1
  hasExercises = true
  amountLearned = 13.37
  duration = 30.min
  bandwidthRequirementPerSecond = 50.mib.toUnit("mb")
}

pklTutorialPart2: TutorialPart = (pklTutorialPart1) {
  name = "Filling out a Template"
  part = 2
}

pklTutorialPart3: TutorialPart = (pklTutorialPart1) {
  name = "Writing a Template"
  part = 3
}

You can read this as saying "pklTutorialPart2 & pklTutorialPart3 are exactly like pklTutorialPart1, except for their name and part." Running Pkl confirms this:

$ pkl eval pklTutorialParts.pkl
pklTutorialPart1 {
  name = "Basic Configuration"
  part = 1
  hasExercises = true
  amountLearned = 13.37
  duration = 30.min
  bandwidthRequirementPerSecond = 50.mb
}
pklTutorialPart2 {
  name = "Filling out a Template"
  part = 2
  hasExercises = true
  amountLearned = 13.37
  duration = 30.min
  bandwidthRequirementPerSecond = 50.mb
}
pklTutorialPart3 {
  name = "Writing a Template"
  part = 3
  hasExercises = true
  amountLearned = 13.37
  duration = 30.min
  bandwidthRequirementPerSecond = 50.mb
}

Sadly, pklTutorialParts.pkl is a rewrite of pklTutorialPart3.pkl. It creates a separate class TutorialPart and instantiates three properties with it (pklTutorialPart1, pklTutorialPart2 and pklTutorialPart3). In doing so, it implicitly moves everything "down" one level (pklTutorialPart3 is now a property in the module pklTutorialParts, whereas above, in pklTutorialPart3.pkl it was its own module). This is not very DRY. As a matter of fact, you don’t need this rewrite.

Any .pkl file defines a module in Pkl. Any module is represented by a module class, which is an actual Pkl class. A module is not quite the same as any other class, because Pkl never renders class definitions on the output. However, when you ran Pkl on pklTutorialPart3.pkl, it did produce an output. This is because a module also defines an instance of the module class.

The values given to properties in a module (or in any "normal" class) are called default values. When you instantiate a class, all the properties for which you don’t provide a value are populated from the class' default values.

In our examples of tutorial parts, only the name and part varied across instances. You can express this by adding default values to the (module) class definition. Instead of starting from a particular tutorial part, you can define the module tutorialPart as follows:

TutorialPart.pkl
name: String (1)

part: Int (1)

hasExercises: Boolean = true (2)

amountLearned: Float = 13.37 (2)

duration: Duration = 30.min (2)

bandwidthRequirementPerSecond: DataSize = 50.mb (2)
1 No default value given.
2 Default value given.

Running this through Pkl gives an error, or course, because of the missing values:

$ pkl eval TutorialPart.pkl
–– Pkl Error ––
Tried to read property `name` but its value is undefined.

1 | name: String
    ^^^^
...

An individual part now only has to fill in the missing fields, so you can change pklTutorialPart3.pkl to amend this:

pklTutorialPart3.pkl
amends "TutorialPart.pkl"

name = "Writing a Template"

part = 3

This results in

$ pkl eval pklTutorialPart3.pkl
name = "Writing a Template"
part = 3
hasExercises = true
amountLearned = 13.37
duration = 30.min
bandwidthRequirementPerSecond = 50.mb

This now behaves exactly like our pklTutorialPart3: TutorialPart = (pklTutorialPart1) {…​ before. pklTutorialPart3 is now defined as the value we get by amending tutorialPart and giving it a name and a part.

Amending anything never changes its type. When we amend an object of type Foo, the result will always be precisely of type Foo. By "precisely" we mean, that amending an object also can’t "turn it into" an instance of a sub-class of the class of the object being amended.

A new template

Now that you know about types, you can start writing your first template. So far, you’ve written configurations with Pkl, either without a template, or using the acmecicd template from Amending templates. It is often easiest to first write a (typical) configuration for which you want to create a template. Suppose you want to define what a live workshop for this tutorial looks like. Consider this example:

workshop2024.pkl
title = "Pkl: Configure your Systems in New Ways"
interactive = true
seats = 100
occupancy = 0.85
duration = 1.5.h
`abstract` = """
  With more systems to configure, the software industry is drowning in repetitive and brittle configuration files.
  YAML and other configuration formats have been turned into programming languages against their will.
  Unsurprisingly, they don’t live up to the task.
  Pkl puts you back in control.
  """

event {
  name = "Migrating Birds between hemispheres"
  year = 2024
}

instructors {
  "Kate Sparrow"
  "Jerome Owl"
}

sessions {
  new {
    date = "2/1/2024"
    time = 30.min
  }
  new {
    date = "2/1/2024"
    time = 30.min
  }
}

assistants {
  ["kevin"] = "Kevin Parrot"
  ["betty"] = "Betty Harrier"
}

agenda {
  ["beginners"] {
    name = "Basic Configuration"
    part = 1
    duration = 45.min
  }
  ["intermediates"] {
    name = "Filling out a Template"
    part = 2
    duration = 45.min
  }
  ["experts"] {
    name = "Writing a Template"
    part = 3
    duration = 45.min
  }
}

Call your new template Workshop.pkl. Although not required, it’s good practice to always name your template with a module-clause. Defining the first few properties are like you saw in the previous section:

module Workshop

title: String

interactive: Boolean

seats: Int

occupancy: Float

duration: Duration

`abstract`: String

Unlike these first few properties, event is an object with multiple properties. To be able to type event, you need a class. You’ve seen before how to define this:

class Event {
  name: String

  year: Int
}

event: Event

Next, instructors isn’t an object with properties, but a list of unnamed values. Pkl offers the Listing type for this:

instructors: Listing<String>

sessions is a Listing of objects, so you need a Session class.

class Session {
  time: Duration

  date: String
}

sessions: Listing<Session>

assistants has a structure like an object, in that all the values are named, but the set of names is not fixed for all possible workshops (and some workshops may have more assistants than others). The Pkl type for this is a Mapping:

assistants: Mapping<String, String>

Finally, for every workshop session, there is an agenda, which describes which TutorialParts are covered. You already defined TutorialPart.pkl as its own module, so you should not define a separate class, but rather import that module and reuse it here:

import "TutorialPart.pkl" (1)

agenda: Mapping<String, TutorialPart>
1 This import clause brings the name TutorialPart into scope, which is the module class as discussed above. Note that import clauses must appear before property definitions.

Putting it all together, your Workshop.pkl template looks like this:

Workshop.pkl
module Workshop

import "TutorialPart.pkl"

title: String

interactive: Boolean

seats: Int

occupancy: Float

duration: Duration

`abstract`: String

class Event {
  name: String

  year: Int
}

event: Event

instructors: Listing<String>

class Session {
  time: Duration

  date: String
}

sessions: Listing<Session>

assistants: Mapping<String, String>

agenda: Mapping<String, TutorialPart>

1. Although, some output formats can contain their own form of type annotation. This may be derived from the Pkl type. Type definitions (class and typealias) themselves are never rendered.