Language Reference
The language reference provides a comprehensive description of every Pkl language feature.
Comments
Numbers
Booleans
Strings
Durations
Data Sizes
Objects
Listings
Mappings
Classes
Methods
Modules
Null Values
If Expressions
Resources
Errors
Debugging
Advanced Topics
For a hands-on introduction, see Tutorial. For ready-to-go examples with full source code, see Examples. For API documentation, see Standard Library.
Comments
Pkl has three forms of comments:
- Line comment
-
A code comment that starts with a double-slash (
//
) and runs until the end of the line.// Single-line comment
- Block comment
-
A nestable multiline comment, which is typically used to comment out code. Starts with
/*
and ends with*/
./* Multiline comment */
- Doc comment
-
A user-facing comment attached to a program member. It starts with a triple-slash (
///
) and runs until the end of the line. Doc comments on consecutive lines are merged./// A *bird* superstar. /// Unfortunately, extinct. dodo: Bird
Doc comments are processed by Pkldoc, Pkl’s documentation generator. For details on their syntax, see Doc Comments.
Numbers
Integers
A value of type Int is a 64-bit signed integer.
Integer literals can be written in decimal, hexadecimal, binary, or octal notation:
num1 = 123
num2 = 0x012AFF (1)
num3 = 0b00010111 (2)
num4 = 0o755 (3)
1 | decimal: 76543 |
2 | decimal: 23 |
3 | decimal: 493 |
Integers can optionally include an underscore as a separator to improve readability. An underscore does not affect the integer’s value.
num1 = 1_000_000 (1)
num2 = 0x0134_64DE (2)
num3 = 0b0001_0111 (3)
num4 = 0o0134_6475 (4)
1 | Equivalent to 1000000 |
2 | Equivalent to 0x013464DE |
3 | Equivalent to 0b00010111 |
4 | Equivalent to 0o01346475 |
Negative integer literals start with a minus sign, as in -123
.
Integers support the standard comparison operators:
comparison1 = 5 == 2
comparison2 = 5 < 2
comparison3 = 5 > 2
comparison4 = 5 <= 2
comparison5 = 5 >= 2
Integers support the following arithmetic operators:
num1 = 5 + 2 (1)
num2 = 5 - 2 (2)
num3 = 5 * 2 (3)
num4 = 5 / 2 (4)
num5 = 5 ~/ 2 (5)
num6 = 5 % 2 (6)
num7 = 5 ** 2 (7)
1 | addition (result: 7 ) |
2 | subtraction (result: 3 ) |
3 | multiplication (result: 10 ) |
4 | division (result: 2.5 , always Float ) |
5 | integer division (result: 2 , always Int ) |
6 | remainder (result: 1 ) |
7 | exponentiation (result: 25 ) |
Arithmetic overflows are caught and result in an error.
To restrict an integer’s range, use one of the predefined type aliases or an isBetween type constraint:
clientPort: UInt16
serverPort: Int(isBetween(0, 1023))
Floats
A value of type Float is a 64-bit double-precision floating point number.
Float literals use decimal notation. They consist of an integer part, decimal point, fractional part, and exponent part. The integer and exponent parts are optional.
num1 = .23
num2 = 1.23
num3 = 1.23e2 (1)
num4 = 1.23e-2 (2)
1 | result: 1.23 * 102 |
2 | result: 1.23 * 10-2 |
Negative float literals start with a minus sign, as in -1.23
.
The special float values not a number, positive infinity, and negative infinity are written as:
notANumber = NaN
positiveInfinity = Infinity
negativeInfinity = -Infinity
Floats support the same comparison and arithmetic operators as integers.
Float literals with a fractional part of zero can be safely replaced with integer literals.
For example, it is safe to write 1.3 * 42
instead of 1.3 * 42.0
.
Floats can also include the same underscore separator as integers. For example, 1_000.4_400
is a float whose value is equivalent to 1000.4400
.
As integers are more convenient to use than floats with a fractional part of zero, we recommend requiring x: Number instead of x: Float in type annotations.
|
To restrict a float to a finite value, use the isFinite type constraint:
x: Float(isFinite)
To restrict a float’s range, use the isBetween type constraint:
x: Float(isBetween(0, 10e6))
Booleans
A value of type Boolean is either true
or false
.
res1 = true && false (1)
res2 = true || false (2)
res3 = !false (3)
res4 = true.xor(false) (4)
res5 = true.implies(false) (5)
1 | logical conjunction (result: false ) |
2 | logical disjunction (result: true ) |
3 | logical negation (result: true ) |
4 | exclusive disjunction (result: true ) |
5 | logical implication (result: false ) |
Strings
A value of type String is a sequence of Unicode code points.
String literals are enclosed in double quotes:
"Hello, World!"
Except for a few minor differences [1], String literals have the same syntax and semantics as in Swift 5. Learn one of them, know both of them! |
Inside a string literal, the following character escape sequences have special meaning:
-
\t
- tab -
\n
- line feed -
\r
- carriage return -
\"
- verbatim quote -
\\
- verbatim backslash
Unicode escape sequences have the form \u{<codePoint>}
, where <codePoint>
is a hexadecimal number between 0 and 10FFFF:
"\u{26} \u{E9} \u{1F600}" (1)
1 | result: "& é 😀" |
To concatenate strings, use the +
(plus) operator, as in "abc" + "def" + "ghi"
.
String Interpolation
To embed the result of expression <expr>
in a string, use \(<expr>)
:
name = "Dodo"
greeting = "Hi, \(name)!" (1)
1 | result: "Hi, Dodo!" |
Before a result is inserted, it is converted to a string:
x = 42
str = "\(x + 2) plus \(x * 2) is \(0x80)" (1)
1 | result: "44 plus 84 is 128" |
Multiline Strings
To write a string that spans multiple lines, use a multiline string literal:
"""
Although the Dodo is extinct,
the species will be remembered.
"""
Multiline string literals are delimited by three double quotes ("""
).
String content and closing delimiter must each start on a new line.
The content of a multiline string starts on the first line after the opening quotes and ends on the last line before the closing quotes.
Line breaks are included in the string and normalized to \n
.
The previous multiline string is equivalent to this single-line string. Notice that there is no leading or trailing whitespace.
"Although the Dodo is extinct,\nthe species will be remembered."
String interpolation, character escape sequences, and Unicode escape sequences work the same as for single-line strings:
bird = "Dodo"
message = """
Although the \(bird) is extinct,
the species will be remembered.
"""
Each content line must begin with the same whitespace characters as the line containing the closing delimiter, which is not included in the string. Any further leading whitespace characters are preserved. In other words, line indentation is controlled by indenting lines relative to the closing delimiter.
In the following string, lines have no leading whitespace:
str = """
Although the Dodo
is extinct,
the species
will be remembered.
"""
In the following string, lines are indented between three and five spaces:
str = """
Although the Dodo
is extinct,
the species
will be remembered.
"""
Custom String Delimiters
Some strings contain many verbatim backslash (\
) or quote ("
) characters.
A good example is regular expressions, which make frequent use of backslash characters for escaping.
In such cases, using the escape sequences \\
and \"
quickly becomes tedious and hampers readability.
Instead, leading/closing string delimiters can be customized to start/end with a pound sign (#
).
This also affects the escape character, which changes from \
to \#
.
All backslash and quote characters in the following string are interpreted verbatim:
#" \\\\\ """"" "#
Escape sequences and string interpolation still work, and now start with \#
:
bird = "Dodo"
str = #" \\\\\ \#n \#u{12AF} \#(bird) """"" "#
More generally, string delimiters and escape characters can be customized to contain n pound signs each, for n >= 1.
In the following string, n is 2. As a result, the string content is interpreted verbatim:
##" \\\\\ \#\#\# """"" "##
String API
The String
class offers a rich API.
Here are just a few examples:
strLength = "dodo".length (1)
reversedStr = "dodo".reverse() (2)
hasAx = "dodo".contains("alive") (3)
trimmed = " dodo ".trim() (4)
1 | result: 4 |
2 | result: "odod" |
3 | result: false |
4 | result: "dodo" |
Durations
A value of type Duration has a value component of type Number
and a unit component of type String
.
The unit component is constrained to the units defined in DurationUnit.
Durations are constructed with the following Number
properties:
duration1 = 5.ns // nanoseconds (smallest unit)
duration2 = 5.us // microseconds
duration3 = 5.ms // milliseconds
duration4 = 5.s // seconds
duration5 = 5.min // minutes
duration6 = 5.h // hours
duration7 = 3.d // days (largest unit)
A duration can be negative, as in -5.min
.
It can have a floating point value, as in 5.13.min
.
The value and unit properties provide access to the duration’s components.
Durations support the standard comparison operators:
comparison1 = 5.min == 3.s
comparison2 = 5.min < 3.s
comparison3 = 5.min > 3.s
comparison4 = 5.min <= 3.s
comparison5 = 5.min >= 3.s
Durations support the same arithmetic operators as numbers:
res1 = 5.min + 3.s (1)
res2 = 5.min - 3.s (2)
res3 = 5.min * 3 (3)
res4 = 5.min / 3 (4)
res5 = 5.min / 3.min (5)
res6 = 5.min ~/ 3 (6)
res7 = 5.min ~/ 3.min (7)
res8 = 5.min % 3 (8)
res9 = 5.min ** 3 (9)
1 | result: 5.05.min |
2 | result: 4.95.min |
3 | result: 15.min |
4 | result: 1.6666666666666667.min |
5 | result: 1.6666666666666667 |
6 | result: 1.min |
7 | result: 1 |
8 | result: 2.min |
9 | result: 125.min |
The value component can be an expression:
x = 5
xMinutes = x.min (1)
y = 3
xySeconds = (x + y).s (2)
1 | result: 5.min |
2 | result: 8.s |
Data Sizes
A value of type DataSize has a value component of type Number
and a unit component of type String
.
The unit component is constrained to the units defined in DataSizeUnit.
Data sizes with decimal units (factor 1000) are constructed with the following Number
properties:
datasize1 = 5.b // bytes (smallest unit)
datasize2 = 5.kb // kilobytes
datasize3 = 5.mb // megabytes
datasize4 = 5.gb // gigabytes
datasize5 = 5.tb // terabytes
datasize6 = 5.pb // petabytes (largest unit)
Data sizes with binary units (factor 1024) are constructed with the following Number
properties:
datasize1 = 5.b // bytes (smallest unit)
datasize2 = 5.kib // kibibytes
datasize3 = 5.mib // mebibytes
datasize4 = 5.gib // gibibytes
datasize5 = 5.tib // tebibytes
datasize6 = 5.pib // pebibytes (largest unit)
A data size can be negative, as in -5.mb
.
It can have a floating point value, as in 5.13.mb
.
The value and unit properties provide access to the data size’s components.
Data sizes support the standard comparison operators:
comparison1 = 5.mb == 3.kib
comparison2 = 5.mb < 3.kib
comparison3 = 5.mb > 3.kib
comparison4 = 5.mb <= 3.kib
comparison5 = 5.mb >= 3.kib
Data sizes support the same arithmetic operators as numbers:
res1 = 5.mb + 3.kib (1)
res2 = 5.mb - 3.kib (2)
res3 = 5.mb * 3 (3)
res4 = 5.mb / 3 (4)
res5 = 5.mb / 3.mb (5)
res6 = 5.mb ~/ 3 (6)
res7 = 5.mb ~/ 3.mb (7)
res8 = 5.mb % 3 (8)
res9 = 5.mb ** 3 (9)
1 | result: 5.003072.mb |
2 | result: 4.996928.mb |
3 | result: 15.mb |
4 | result: 1.6666666666666667.mb |
5 | result: 1.6666666666666667 |
6 | result: 1.mb |
7 | result: 1 |
8 | result: 2.mb |
9 | result: 125.mb |
The value component can be an expression:
x = 5
xMegabytes = x.mb (1)
y = 3
xyKibibytes = (x + y).kib (2)
1 | result: 5.mb |
2 | result: 8.kib |
Objects
An object is an ordered collection of values indexed by name.
An object’s key-value pairs are called its properties. Property values are lazily evaluated on the first read.
Because Pkl’s objects differ in important ways from objects in general-purpose programming languages, and because they are the backbone of most data models, understanding objects is the key to understanding Pkl.
Defining Objects
Let’s define an object with properties name
and extinct
:
dodo { (1)
name = "Dodo" (2)
extinct = true (3)
} (4)
1 | Defines a module property named dodo .
The open curly brace ({ ) indicates that the value of this property is an object. |
2 | Defines an object property named name with string value "Dodo" . |
3 | Defines an object property named extinct with boolean value true . |
4 | The closing curly brace indicates the end of the object definition. |
To access an object property by name, use dot (.
) notation:
dodoName = dodo.name
dodoIsExtinct = dodo.extinct
Objects can be nested:
dodo {
name = "Dodo"
taxonomy { (1)
`class` = "Aves" (2)
}
}
1 | Defines an object property named taxonomy .
The open curly brace indicates that its value is another object. |
2 | The word class is a keyword of Pkl, and needs to be wrapped in backticks (` ) to be used as a property. |
As you probably guessed, the nested property class
can be accessed with dodo.taxonomy.class
.
Like all values, objects are immutable, which is just a fancy (and short!) way to say that their properties never change. So what happens when Dodo moves to a different street? Do we have to construct a new object from scratch?
Amending Objects
Fortunately, we don’t have to. An object can be amended to form a new object that only differs in selected properties. Here is how this looks:
tortoise = (dodo) { (1)
name = "Galápagos tortoise"
taxonomy { (2)
`class` = "Reptilia" (3)
}
}
1 | Defines a module property named tortoise .
Its value is an object that amends dodo .
Note that the amended object must be enclosed in parentheses. |
2 | Object property tortoise.taxonomy amends dodo.taxonomy . |
3 | Object property tortoise.taxonomy.class overrides dodo.taxonomy.class . |
As you can see, it is easy to construct a new object that overrides selected properties of an existing object, even if, as in our example, the overridden property is nested inside another object.
If this way of constructing new objects from existing objects reminds you of prototypical inheritance, you are spot-on: Pkl objects use prototypical inheritance as known from languages such as JavaScript. But unlike in JavaScript, their prototype chain cannot be directly accessed or even modified. Another difference is that in Pkl, object properties are late-bound. Read on to see what this means. |
Amends declaration vs. amends expression
The defining objects and amending objects sections cover two notations that are both a form of amending; called an amends declaration and an amends expression, respectively.
An amends declaration amends a property of the same name if the property exists within a parent module. Otherwise, an amends declaration implicitly amends Dynamic. Another way to think about an amends declaration is that it is shorthand for assignment.
In practical terms, Amending object bodies can be chained for both an amends declaration and an amends expression.
|
Late Binding
Let’s move on to Pkl’s secret sauce: the ability to define an object property’s value in terms of another property’s value, and the resulting late binding effect. Here is an example:
penguin {
eggIncubation = 40.d
adultWeightInGrams = eggIncubation.value * 100 (1)
}
adultWeightInGrams = penguin.adultWeightInGrams
1 | result: 4000 |
We have defined a hypothetical penguin
object whose adultWeightInGrams
property is defined in terms of the eggIncubation
duration.
Can you guess what happens when penguin
is amended and its eggIncubation
overridden?
madeUpBird = (penguin) {
eggIncubation = 11.d
}
adultWeightInGrams = madeUpBird.adultWeightInGrams (1)
1 | result: 1100 |
As you can see, madeUpBird
's adultWeightInGrams
changed along with its eggIncubation
.
This is what we mean when we say that object properties are late-bound.
Spreadsheet Programming
A good analogy is that object properties behave like spreadsheet cells. When they are linked, changes to "downstream" properties automatically propagate to "upstream" properties. The main difference is that editing a spreadsheet cell changes the state of the spreadsheet, whereas "editing" a property results in a new object, leaving the original object untouched. It is as if you made a copy of the entire spreadsheet whenever you edited a cell! |
Late binding of properties is an incredibly useful feature for a configuration language. It is used extensively in Pkl code (especially in templates) and is the key to understanding how Pkl works.
Transforming Objects
Say we have the following object:
dodo {
name = "Dodo"
extinct = true
}
How can property name
be removed?
The recipe for transforming an object is:
-
Convert the object to a map.
-
Transform the map using
Map
's rich API. -
If necessary, convert the map back to an object.
Equipped with this knowledge, let’s try to accomplish our objective:
dodo
.toMap()
.remove("name")
.toDynamic()
The resulting dynamic object is equivalent to dodo
, except that it no longer has a name
property.
Lazy vs. Eager Data Types
Converting an object to a map is a transition from a lazy to an eager data type. All of the object’s properties are evaluated and all references between them are resolved. If the map is later converted back to an object, subsequent changes to the object’s properties no longer propagate to (previously) dependent properties.
To make these boundaries clear, transitioning between lazy and eager data types always requires an explicit method call, such as |
Typed Objects
Pkl has two kinds of objects:
-
A Dynamic object has no predefined structure.[2] When a dynamic object is amended, not only can existing properties be overridden or amended, but new properties can also be added. So far, we have only used dynamic objects in this chapter.
-
A Typed object has a fixed structure described by a class definition. When a typed object is amended, its properties can be overridden or amended, but new properties cannot be added. In other words, the new object has the same class as the original object.
When to Use Typed vs. Dynamic Objects
|
Note that every module is a typed object. Its properties implicitly define a class, and new properties cannot be added when amending the module.
A typed object is backed by a class. Let’s look at an example:
class Bird { (1)
name: String
lifespan: Int
migratory: Boolean
}
pigeon = new Bird { (2)
name = "Pigeon"
lifespan = 8
migratory = false
}
1 | Defines a class named Bird with properties name , lifespan and migratory . |
2 | Defines a module property named pigeon .
Its value is a typed object constructed by instantiating class Bird .
A type only needs to be stated when the property does not have or inherit a type annotation.
Otherwise, amend syntax (pigeon { … } ) or shorthand instantiation syntax (pigeon = new { … } ) should be used. |
Congratulations, you have constructed your first typed object[4]! How does it differ from a dynamic object?
The answer is that a typed object has a fixed structure prescribed by its class, which cannot be changed when amending the object:
class Bird { (1)
name: String
lifespan: Int
}
faultyPigeon = new Bird {
name = "Pigeon"
lifespan = 8
hobby = "singing"
}
Evaluating this gives:
Cannot find property hobby in object of type repl#Bird.
Available properties:
lifespan
name
Class structure is also enforced when instantiating a class.
Let’s try to override property name
with a value of the wrong type:
faultyPigeon2 = new Bird {
name = 3.min
lifespan = 8
}
Evaluating this, also fails:
Expected value of type String, but got type Duration.
Value: 3.min
Typed objects are the fundamental building block for constructing validated data models in Pkl. To dive deeper into this topic, continue with Classes.
Converting untyped objects to typed objects
When you have a
|
Property Modifiers
Hidden Properties
A property with the modifier hidden
is omitted from the rendered output and object conversions.
Hidden properties are also ignored when evaluating equality or hashing (e.g. for Mapping
or Map
keys).
class Bird {
name: String
lifespan: Int
hidden nameAndLifespanInIndex = "\(name), \(lifespan)" (1)
nameSignWidth: UInt = nameAndLifespanInIndex.length (2)
}
pigeon = new Bird { (3)
name = "Pigeon"
lifespan = 8
}
pigeonInIndex = pigeon.nameAndLifespanInIndex (4)
pigeonDynamic = pigeon.toDynamic() (5)
favoritePigeon = (pigeon) {
nameAndLifespanInIndex = "Bettie, \(lifespan)"
}
samePigeon = pigeon == favoritePigeon (6)
1 | Properties defined as hidden are accessible on any Bird instance, but not output by default. |
2 | Non-hidden properties can refer to hidden properties as usual. |
3 | pigeon is an object with four properties, but is rendered with three properties. |
4 | Accessing a hidden property from outside the class and object is like any other property. |
5 | Object conversions omit hidden properties, so the resulting Dynamic has three properties. |
6 | Objects that differ only in hidden property values are considered equal |
Invoking Pkl on this file produces the following result.
pigeon {
name = "Pigeon"
lifespan = 8
nameSignWidth = 9
}
pigeonInIndex = "Pigeon, 8"
pigeonDynamic {
name = "Pigeon"
lifespan = 8
nameSignWidth = 9
}
favoritePigeon {
name = "Pigeon"
lifespan = 8
nameSignWidth = 9
}
samePigeon = true
Local properties
A property with the modifier local
can only be referenced in the lexical scope of its definition.
class Bird {
name: String
lifespan: Int
local separator = "," (1)
hidden nameAndLifespanInIndex = "\(name)\(separator) \(lifespan)" (2)
}
pigeon = new Bird {
name = "Pigeon"
lifespan = 8
}
pigeonInIndex = pigeon.nameAndLifespanInIndex (3)
pigeonSeparator = pigeon.separator // Error (4)
1 | This property can only be accessed from inside this class definition. |
2 | Non-local properties can refer to the local property as usual. |
3 | The value of separator occurs in nameAndLifespanInIndex . |
4 | Pkl does not accept this, as there is no property separator on a Bird instance. |
Because a local
property is added to the lexical scope, but not (observably) to the object, you can add local
properties to Listing
s and Mapping
s.
Import clauses define local properties
An import clause defines a local property in the containing module.
This means |
Fixed properties
A property with the fixed
modifier cannot be assigned to or amended when defining an object of its class.
fixed laysEggs: Boolean = true
fixed birds: Listing<String> = new {
"Pigeon"
"Hawk"
"Penguin"
}
When amending, assigning to a fixed
property is an error.
Similarly, it is an error to use an amends declaration on a fixed property:
amends "Bird.pkl"
laysEggs = false (1)
birds { (2)
"Giraffe"
}
1 | Error: cannot assign to fixed property laysEggs |
2 | Error: cannot amend fixed property birds |
When extending a class and overriding an existing property definition, the fixedness of the overridden property must be preserved.
If the property in the parent class is declared fixed
, the child property must also be declared fixed
.
If the property in the parent class is not declared fixed
, the child property may not add the fixed
modifier.
abstract class Bird {
fixed canFly: Boolean
name: String
}
class Penguin extends Bird {
canFly = false (1)
fixed name = "Penguin" (2)
}
1 | Error: missing modifier fixed . |
2 | Error: modifier fixed cannot be applied to property name . |
The fixed
modifier is useful for defining properties that are meant to be derived from other properties.
In the following snippet, the property result
is not meant to be assigned to, because it is derived
from other properties.
class Bird {
wingspan: Int
weight: Int
fixed wingspanWeightRatio: Int = wingspan / weight
}
Another use-case for fixed
is to define properties that are meant to be fixed to a class definition.
In the example below, the species
of a bird is tied to the class, and therefore is declared fixed
.
Note that it is possible to define a fixed
property without a value, for one of two reasons:
-
The type has a default value that makes an explicit default redundant.
-
The property is meant to be overridden by a child class.
abstract class Bird {
fixed species: String (1)
}
class Osprey extends Bird {
fixed species: "Pandion haliaetus" (2)
}
1 | No explicit default because the property is overridden by a child class. |
2 | Overrides the type from String to the string literal type "Pandion haliaetus" .Assigning an explicit default would be redundant, therefore it is omitted. |
Const properties
A property with the const
modifier behaves like the fixed
modifier,
with the additional rule that it cannot reference non-const properties or methods.
const laysEggs: Boolean = true
const examples: Listing<String> = new {
"Pigeon"
"Hawk"
"Penguin"
}
Referencing any non-const property or method is an error.
pigeonName: String = "Pigeon"
const function birdLifespan(i: Int): Int = (i / 4).toInt()
class Bird {
name: String
lifespan: Int
}
const bird: Bird = new {
name = pigeonName (1)
lifespan = birdLifespan(24) (2)
}
1 | Error: cannot reference non-const property pigeonName from a const property. |
2 | Allowed: birdLifespan is const. |
It is okay to reference another value within the same const property.
class Bird {
lifespan: Int
description: String
speciesName: "Bird"
}
const bird: Bird = new {
lifespan = 8
description = "species: \(speciesName), lifespan: \(lifespan)" (1)
}
1 | lifespan is declared within property bird . speciesName resolves to this.speciesName , where this is a value within property bird . |
Because const members can only reference themselves and other const members, they are not late bound.
|
The const
modifier implies that it is also fixed.
Therefore, the same rules that apply to fixed
also apply to const
:
-
A
const
property cannot be assigned to or amended when defining an object of its class. -
The const-ness of a property or method must be preserved when it is overridden by a child class.
Class, Annotation, and Typealias Scoping
In these following scenarios, any reference to a property or method of its enclosing module requires that the referenced member is const
:
-
Class body
-
Annotation body
-
Typealiased constrained type
pigeonName: String = "Pigeon"
class Bird {
name: String = pigeonName (1)
}
@Deprecated { message = "Replace with \(pigeonName)" } (2)
oldPigeonName: String
typealias IsPigeonName = String(pigeonName) (3)
1 | Error: cannot reference non-const property pigeonName from a class. |
2 | Error: cannot reference non-const property pigeonName from an annotation. |
3 | Error: cannot reference non-const property pigeonname from a typealias. |
This rule exists because classes, annotations, and typealiases are not late bound; it is not possible to change the definition of these members by amending the module where it is defined.
Generally, there are two strategies for referencing such properties:
Add the const
modifier to the referenced property
One solution is to add the const
modifier to the property being referenced.
-pigeonName: String = "Pigeon"
+const pigeonName: String = "Pigeon"
class Bird {
name: String = pigeonName
}
This solution makes sense if pigeonName
does not get assigned/amended when amending module Birds.pkl
(modules are regular objects that can be amended).
Self-import the module
+import "Birds.pkl" (1)
+
pigeonName: String = "Pigeon"
class Bird {
- name: String = pigeonName
+ name: String = Birds.pigeonName
}
1 | module Birds imports itself |
This solution works because an import clause implicitly defines a const local
property and amending this module does not affect a self-import.
This makes sense if property pigeonName
does get assigned/amended when amending module Birds.pkl
.
Listings
A value of type Listing is an ordered, indexed collection of elements.
A listing’s elements have zero-based indexes and are lazily evaluated on the first read.
Listings combine qualities of lists and objects:
-
Like lists, listings can contain arbitrary elements.
-
Like objects, listings excel at defining and amending nested literal data structures.
-
Like objects, listings can only be directly manipulated through amendment, but converting them to a list (and, if necessary, back to a listing) opens the door to arbitrary transformations.
-
Like object properties, listing elements are evaluated lazily, can be defined in terms of each other, and are late-bound.
When to use Listing vs. List
Templates and schemas should almost always use listings instead of lists. Note that listings can be converted to lists when the need arises. |
Defining Listings
Listings have a literal syntax that is similar to that of objects. Here is a listing with two elements:
birds = new Listing { (1)
new { (2)
name = "Pigeon"
diet = "Seed"
}
new { (3)
name = "Parrot"
diet = "Berries"
}
}
1 | Defines a module property named birds with a value of type Listing .
A type only needs to be stated when the property does not have or inherit a type annotation.
Otherwise, amend syntax (birds { … } ) or shorthand instantiation syntax (birds = new { … } ) should be used. |
2 | Defines a listing element of type Dynamic . |
3 | Defines another listing element of type Dynamic .
The order of definitions is relevant. |
To access an element by index, use the []
(subscript) operator:
firstBirdName = birds[0].name (1)
secondBirdDiet = birds[1].diet (2)
1 | result: "Pigeon" |
2 | result: "Berries" |
Listings can contain arbitrary types of elements:
listing = new Listing {
"Pigeon" (1)
3.min (2)
new Listing { (3)
"Barn owl"
}
}
1 | Defines a listing element of type String . |
2 | Defines a listing element of type Duration . |
3 | Defines a listing element of type Listing . |
Listings can have local
properties:
listing = new Listing {
local pigeon = "Pigeon" (1)
pigeon (2)
"A " + pigeon + " is a bird" (3)
}
1 | Defines a local property with the value "Pigeon" .
Local properties can have a type annotation, as in pigeon: String = "Pigeon" . |
2 | Defines a listing element that references the local property. |
3 | Defines another listing element that references the local property. |
Amending Listings
Let’s say we have the following listing:
birds = new Listing {
new {
name = "Pigeon"
diet = "Seeds"
}
new {
name = "Parrot"
diet = "Berries"
}
}
To add, override, or amend elements of this listing, amend the listing itself:
birds2 = (birds) { (1)
new { (2)
name = "Barn owl"
diet = "Mice"
}
[0] { (3)
diet = "Worms"
}
[1] = new { (4)
name = "Albatross"
diet = "Fish"
}
}
1 | Defines a module property named birds2 . Its value is a listing that amends birds . |
2 | Defines a listing element of type Dynamic . |
3 | Amends the listing element at index 0 (whose name is "Pigeon" ) and overrides property diet . |
4 | Overrides the listing element at index 1 (whose name is "Parrot" ) with an entirely new dynamic object. |
Late Binding
A listing element can be defined in terms of another element.
To reference the element at index <index>
, use this[<index>]
:
birds = new Listing {
new { (1)
name = "Pigeon"
diet = "Seeds"
}
(this[0]) { (2)
name = "Parrot"
}
}
1 | Defines a listing element of type Dynamic . |
2 | Defines a listing element that amends the element at index 0 and overrides name . |
Listing elements are late-bound:
newBirds = (birds) { (1)
[0] {
diet = "Worms"
}
}
secondBirdDiet = newBirds[1].diet (2)
1 | Amends listing birds and overrides property diet of element 0 (whose name is "Pigeon"`) to have the value "Worms" . |
2 | Because element 1 is defined in terms of element 0, its diet property also changes to "Worms" . |
Transforming Listings
Say we have the following listing:
birds = new Listing {
new {
name = "Pigeon"
diet = "Seeds"
}
new {
name = "Parrot"
diet = "Berries"
}
}
How can the order of elements be reversed programmatically?
The recipe for transforming a listing is:
-
Convert the listing to a list.
-
Transform the list using
List
's rich API. -
If necessary, convert the list back to a listing.
Often, transformations happen in a converter of a value renderer. Because most value renderers treat lists the same as listings, it is often not necessary to convert back to a listing. |
Equipped with this knowledge, let’s try to accomplish our objective:
reversedbirds = birds
.toList()
.reverse()
.toListing()
result
now contains the same elements as birds
, but in reverse order.
Lazy vs. Eager Data Types
Converting a listing to a list is a transition from a lazy to an eager data type. All of the listing’s elements are evaluated and all references between them are resolved. If the list is later converted back to a listing, subsequent changes to the listing’s elements no longer propagate to (previously) dependent elements.
To make these boundaries clear, transitioning between lazy and eager data types always requires an explicit method call, such as |
Default Element
Listings can have a default element:
birds = new Listing {
default { (1)
lifespan = 8
}
new { (2)
name = "Pigeon" (3)
}
new { (4)
name = "Parrot"
lifespan = 20 (5)
}
}
1 | Amends the default element and sets property lifespan . |
2 | Defines a new listing element that implicitly amends the default element. |
3 | Defines a new property called name . Property lifespan is inherited from the default element. |
4 | Defines a new listing element that implicitly amends the default element. |
5 | Overrides the default for property lifespan . |
default
is a hidden (that is, not rendered) property defined in class Listing
.
If birds
had a type annotation, a suitable default element would be inferred from its type parameter.
If, as in our example, no type annotation is provided or inherited, the default element is the empty Dynamic
object.
Like regular listing elements, the default element is late-bound. As a result, defaults can be changed retroactively:
birds2 = (birds) {
default {
lifespan = 8
diet = "Seeds"
}
}
Because both of birds
's elements amend the default element, changing the default element also changes them.
An equivalent literal definition of birds2
would look as follows:
birds2 = new Listing {
new {
name = "Pigeon"
lifespan = 8
diet = "Seeds"
}
new {
name = "Parrot"
lifespan = 20
diet = "Berries"
}
}
Note that Parrot kept its diet because its prior self defined it explicitly, overriding any default.
If you are interested in the technical underpinnings of default elements (and not afraid of dragons!), continue with Function Amending.
Type Annotations
To declare the type of a property that is intended to hold a listing, use:
x: Listing<ElementType>
This declaration has the following effects:
-
x
is initialized with an empty listing. -
If
ElementType
has a default value, that value becomes the listing’s default element. -
The first time
x
is read,-
its value is checked to have type
Listing
. -
the listing’s elements are checked to have the type
ElementType
.
-
Here is an example:
class Bird {
name: String
lifespan: Int
}
birds: Listing<Bird>
Because the default value for type Bird
is new Bird {}
, that value becomes the listing’s default element.
Let’s go ahead and populate birds
:
birds {
new {
name = "Pigeon"
lifespan = 8
}
new {
name = "Parrot"
lifespan = 20
}
}
Thanks to birds
's default element, which was inferred from its type,
it is not necessary to state the type of each list element
(new Bird { … }
, new Bird { … }
, etc.).
Distinct Elements
To constrain a listing to distinct elements, use Listing
's isDistinct property:
class Bird {
name: String
lifespan: Int
}
birds: Listing<Bird>(isDistinct)
This is as close as Pkl’s late-bound data types (objects, listings, and mappings) get to a Set.
To demand distinct names instead of distinct Bird
objects, use isDistinctBy():
birds: Listing<Bird>(isDistinctBy((it) -> it.name))
Mappings
A value of type Mapping is an ordered collection of values indexed by key.
Most of what has been said about listings also applies to mappings. Nevertheless, this section is written to stand on its own. |
A mapping’s key-value pairs are called its entries. Keys are eagerly evaluated; values are lazily evaluated on the first read.
Mappings combine qualities of maps and objects:
-
Like maps, mappings can contain arbitrary key-value pairs.
-
Like objects, mappings excel at defining and amending nested literal data structures.
-
Like objects, mappings can only be directly manipulated through amendment, but converting them to a map (and, if necessary, back to a mapping) opens the door to arbitrary transformations.
-
Like object properties, a mapping’s values (but not its keys) are evaluated lazily, can be defined in terms of each other, and are late-bound.
When to use Mapping vs. Map
Templates and schemas should almost always use mappings instead of maps. Note that mappings can be converted to maps when the need arises. |
Defining Mappings
Mappings have the same literal syntax as objects, except that keys enclosed in []
take the place of property names.
Here is a mapping with two entries:
birds = new Mapping { (1)
["Pigeon"] { (2)
lifespan = 8
diet = "Seeds"
}
["Parrot"] { (3)
lifespan = 20
diet = "Berries"
}
}
1 | Defines a module property named birds with a value of type Mapping .
A type only needs to be stated when the property does not have or inherit a type annotation.
Otherwise, amend syntax (birds { … } ) or shorthand instantiation syntax (birds = new { … } ) should be used. |
2 | Defines a mapping entry with the key "Pigeon" and a value of type Dynamic . |
3 | Defines a mapping entry with the key "Parrot" and a value of type Dynamic . |
To access a value by key, use the []
(subscript) operator:
pigeon = birds["Pigeon"]
parrot = birds["Parrot"]
Mappings can contain arbitrary types of values:
mapping = new Mapping {
["number"] = 42
["list"] = List("Pigeon", "Parrot")
["nested mapping"] {
["Pigeon"] {
lifespan = 20
diet = "Seeds"
}
}
}
Although string keys are most common, mappings can contain arbitrary types of keys:
mapping = new Mapping {
[3.min] = 42
[new Dynamic { name = "Pigeon" }] = "abc"
}
Keys can be computed:
mapping = new Mapping {
["Pigeon".reverse()] = 42
}
Mappings can have local
properties:
mapping = new Mapping {
local parrot = "Parrot" (1)
["Pigeon"] { (2)
friend = parrot
}
}
1 | Defines a local property name parrot with the value "Parrot" .
Local properties can have a type annotation, as in parrot: String = "Parrot" . |
2 | Defines a mapping entry whose value references parrot .
The local property is visible to values but not keys. |
Amending Mappings
Let’s say we have the following mapping:
birds = new Mapping {
["Pigeon"] {
lifespan = 8
diet = "Seeds"
}
["Parrot"] {
lifespan = 20
diet = "Berries"
}
}
To add, override, or amend entries of this mapping, amend the mapping:
birds2 = (birds) { (1)
["Barn owl"] { (2)
lifespan = 15
diet = "Mice"
}
["Pigeon"] { (3)
diet = "Seeds"
}
["Parrot"] = new { (4)
lifespan = 20
diet = "Berries"
}
}
1 | Defines a module property named birds2 . Its value is a mapping that amends birds . |
2 | Defines a mapping entry with the key "Barn owl" and a value of type Dynamic . |
3 | Amends mapping entry "Pigeon" and overrides property diet . |
4 | Overrides mapping entry "Parrot" with an entirely new value of type Dynamic . |
Late Binding
A mapping entry’s value can be defined in terms of another entry’s value.
To reference the value with key <key>
, use this[<key>]
:
birds = new Mapping {
["Pigeon"] { (1)
lifespan = 8
diet = "Seeds"
}
["Parrot"] = (this["Pigeon"]) { (2)
lifespan = 20
}
}
1 | Defines a mapping entry with the key "Pigeon" and a value of type Dynamic . |
2 | Defines a mapping entry with the key "Parrot" and a value that amends "Pigeon" . |
Mapping values are late-bound:
birds2 = (birds) { (1)
["Pigeon"] {
diet = "Seeds"
}
}
parrotDiet = birds2["Parrot"].diet (2)
1 | Amends mapping birds and overrides "Pigeon" 's diet property to have value "Seeds" . |
2 | Because "Parrot" is defined in terms of "Pigeon" , its diet property also changes to "Seeds" . |
Transforming Mappings
Say we have the following mapping:
birds = new Mapping {
["Pigeon"] {
lifespan = 8
diet = "Seeds"
}
["Parrot"] = (this["Pigeon"]) {
lifespan = 20
}
}
How can birds
's keys be reversed programmatically?
The recipe for transforming a mapping is:
-
Convert the mapping to a map.
-
Transform the map using
Map
's rich API. -
If necessary, convert the map back to a mapping.
Often, transformations happen in a converter of a value renderer. As most value renderers treat maps the same as mappings, it is often not necessary to convert back to a mapping. |
Equipped with this knowledge, let’s try to accomplish our objective:
result = birds
.toMap()
.mapKeys((key, value) -> key.reverse())
.toMapping()
result
contains the same values as birds
, but its keys have changed to "noegiP"
and "torraP"
.
Lazy vs. Eager Data Types
Converting a mapping to a map is a transition from a lazy to an eager data type. All of the mapping’s values are evaluated and all references between them are resolved. (Mapping keys are eagerly evaluated.) If the map is later converted back to a mapping, changes to the mapping’s values no longer propagate to (previously) dependent values.
To make these boundaries clear, transitioning between lazy and eager data types always requires an explicit method call, such as |
Default Value
Mappings can have a default value:
birds = new Mapping {
default { (1)
lifespan = 8
}
["Pigeon"] { (2)
diet = "Seeds" (3)
}
["Parrot"] { (4)
lifespan = 20 (5)
}
}
1 | Amends the default value and sets property lifespan . |
2 | Defines a mapping entry with the key "Pigeon" that implicitly amends the default value. |
3 | Defines a new property called diet . Property lifespan is inherited from the default value. |
4 | Defines a mapping entry with the key "Parrot" that implicitly amends the default value. |
5 | Overrides the default for property lifespan . |
default
is a hidden (that is, not rendered) property defined in class Mapping
.
If birds
had a type annotation, a suitable default value would be inferred from its second type parameter.
If, as in our example, no type annotation is provided or inherited, the default value is the empty Dynamic
object.
Like regular mapping values, the default value is late-bound. As a result, defaults can be changed retroactively:
birds2 = (birds) {
default {
lifespan = 8
diet = "Seeds"
}
}
Because both of birds
's mapping values amend the default value, changing the default value also changes them.
An equivalent literal definition of birds2
would look as follows:
birds2 = new Mapping {
["Pigeon"] {
lifespan = 8
diet = "Seeds"
}
["Parrot"] {
lifespan = 20
diet = "Berries"
}
}
Note that Parrot kept its lifespan because its prior self defined it explicitly, overriding any default.
If you are interested in the technical underpinnings of default values, continue with Function Amending.
Type Annotations
To declare the type of a property that is intended to hold a mapping, use:
x: Mapping<KeyType, ValueType>
This declaration has the following effects:
-
x
is initialized with an empty mapping. -
If
ValueType
has a default value, that value becomes the mapping’s default value. -
The first time
x
is read,-
its value is checked to have type
Mapping
. -
the mapping’s keys are checked to have type
KeyType
. -
the mapping’s values are checked to have type
ValueType
.
-
Here is an example:
class Bird {
lifespan: Int
}
birds: Mapping<String, Bird>
Because the default value for type Bird
is new Bird {}
, that value becomes the mapping’s default value.
Let’s go ahead and populate birds
:
birds {
["Pigeon"] {
lifespan = 8
}
["Parrot"] {
lifespan = 20
}
}
Thanks to birds
's default value, which was inferred from its type,
it is not necessary to state the type of each mapping value
(["Pigeon"] = new Bird { … }
, ["Parrot"] = new Bird { … }
, etc.).
Classes
Classes are arranged in a single inheritance hierarchy. At the top of the hierarchy sits class Any; at the bottom, type nothing.
Classes contain properties and methods, which can be local
to their declaring scope.
Properties can also be hidden
from rendering.
class Bird {
name: String
hidden taxonomy: Taxonomy
}
class Taxonomy {
`species`: String
}
pigeon: Bird = new {
name = "Common wood pigeon"
taxonomy {
species = "Columba palumbus"
}
}
pigeonClass = pigeon.getClass()
Declaration of new class instances will fail when property names are misspelled:
// Detects the spelling mistake
parrot = new Bird {
namw = "Parrot"
}
Class Inheritance
Pkl supports single inheritance with a Java(Script)-like syntax.
abstract class Bird {
name: String
}
class ParentBird extends Bird {
kids: List<String>
}
pigeon: ParentBird = new {
name = "Old Pigeon"
kids = List("Pigeon Jr.", "Teen Pigeon")
}
Methods
Pkl methods can be defined on classes and modules using the function
keyword.
Methods may access properties of their containing type.
Submodules and subclasses can override them.
Like Java and most other object-oriented languages, Pkl uses single dispatch — methods are dynamically dispatched based on the receiver’s runtime type.
class Bird {
name: String
function greet(bird: Bird): String = "Hello, \(bird.name)!" (1)
}
function greetPigeon(bird: Bird): String = bird.greet(pigeon) (2)
pigeon: Bird = new {
name = "Pigeon"
}
parrot: Bird = new {
name = "Parrot"
}
greeting1 = pigeon.greet(parrot) (3)
greeting2 = greetPigeon(parrot) (4)
1 | Instance method of class Bird . |
2 | Module method. |
3 | Call instance method on pigeon . |
4 | Call module method (on this ). |
Methods do not support named parameters or default parameter values. The Class-as-a-function pattern may be a suitable replacement. |
In most cases, methods without parameters should not be defined.
Instead, use fixed properties on the module or class.
|
Modules
Introduction
Modules are the unit of loading, executing, and sharing Pkl code.
Every file containing Pkl code is a module.
By convention, module files have a .pkl
extension.
Modules have a Module Name and are loaded from a Module URI.
At runtime, modules are represented as objects of type Module.
The precise runtime type of a module is a subclass of Module
containing the module’s property and method definitions.
Like class members, module members may have type annotations, which are validated at runtime:
timeout: Duration(isPositive) = 5.ms
function greet(name: String): String = "Hello, \(name)!"
Because modules are regular objects, they can be assigned to properties and passed to and returned from methods.
Module Names
Modules may declare their name by way of a module clause, which consists of the keyword module
followed by a qualified name:
/// My bird module.
module com.animals.Birds
A module clause must come first in a module. Its doc comment, if present, holds the module’s overall documentation.
In the absence of a module clause, a module’s name is inferred from the module URI from which the module was first loaded.
For example, the inferred name for a module first loaded from https://example.com/pkl/bird.pkl
is bird
.
Module names do not affect evaluation but are used in diagnostic messages and Pkldoc.
In particular, they are the first component (everything before the hash sign) of fully qualified member names such as pkl.base#Int
.
Modules shared with other parties should declare a qualified module name, which is more unique and stable than an inferred name. |
Module URIs
Modules are loaded from module URIs.
By default, the following URI types are available for import:
HTTP(S) URI:
Example: https://example.com/my_module.pkl
Represents a module imported via an HTTP(S) GET request.
Modules loaded from HTTP(S) URIs are only cached until the pkl command exits or the Evaluator object is closed.
|
Module path URI:
Example: modulepath:/path/to/my_module.pkl
Module path URIs are resolved relative to the module path, a search path for modules similar to Java’s class path (see the --module-path
CLI option).
For example, given the module path /dir1:/zip1.zip:/jar1.jar
, module modulepath:/path/to/my_module.pkl
will be searched for in the following locations:
-
/dir1/path/to/my_module.pkl
-
/path/to/my_module.pkl
within/zip1.zip
-
/path/to/my_module.pkl
within/jar1.jar
When evaluating Pkl code from Java, modulepath:/path/to/my_module.pkl
corresponds to class path location path/to/my_module.pkl
.
In a typical Java project, this corresponds to file path src/main/resources/path/to/my_module.pkl
or src/test/resources/path/to/my_module.pkl
.
Package asset URI:
Example: package://example.com/mypackage@1.0.0#/my_module.pkl
Represent a module within a package. A package is a shareable archive of modules and resources that are published to the internet.
To import package://example.com/mypackage@1.0.0#/my_module.pkl
, Pkl follows these steps:
-
Make an HTTPS GET request to
https://example.com/mypackage@1.0.0
to retrieve the package’s metadata. -
From the package metadata, download the referenced zip archive, and validate its checksum.
-
Resolve path
/my_module.pkl
within the package’s zip archive.
A package asset URI has the following form:
'package://' <host> <path> '@' <semver> '#' <asset path>
Optionally, the SHA-256 checksum of the package can also be specified:
'package://' <host> <path> '@' <semver> '::sha256:' <sha256-checksum> '#' <asset path>
Packages can be managed as dependencies within a project. For more details, consult the project section of the language reference.
Standard Library URI
Example: pkl:math
Standard library modules are named pkl.<simpleName>
and have module URIs of the form pkl:<simpleName>
.
For example, module pkl.math
has module URI pkl:math
.
See the API Docs for the complete list of standard library modules.
Relative URIs
Relative module URIs are interpreted relative to the URI of the enclosing module.
For example, a module with URI modulepath:/animals/birds/pigeon.pkl
can import modulepath:/animals/birds/parrot.pkl
with import "parrot.pkl"
or import "/animals/birds/parrot.pkl"
.
Paths on Windows
Relative paths use the Paths that contain drive letters (e.g. |
When importing a relative directory or file that starts with @ , the import string must be prefixed with ./ .
Otherwise, this syntax will be interpreted as dependency notation.
|
Dependency notation URIs
Example: @birds/bird.pkl
Dependency notation URIs represent a path within a project or package dependency.
For example, import @birds/bird.pkl
represents path /bird.pkl
in a dependency named "birds".
A dependency is either a remote package or a local project dependency.
Extension points
Pkl embedders can register additional module loaders that recognize other types of module URIs.
Evaluation
Module URIs can be evaluated directly:
$ pkl eval path/to/mymodule.pkl
$ pkl eval file:///path/to/my_module.pkl
$ pkl eval https://apple.com/path/to/mymodule.pkl
$ pkl eval --module-path=/pkl-modules modulepath:/path/to/my_module.pkl
$ pkl eval pkl:math
Triple-dot Module URIs
To simplify referencing ancestor modules in a hierarchical module structure,
relative file and module path URIs may start with .../
, a generalization of ../
.
Module URI .../foo/bar/baz.pkl
resolves to the first existing module among
../foo/bar/baz.pkl
, ../../foo/bar/baz.pkl
, ../../../foo/bar/baz.pkl
, and so on.
Furthermore, module URI ...
is equivalent to .../<currentFileName>
.
Using triple-dot module URIs never resolve to the current module.
For example, a module at path foo/bar.pkl
that references module URI .../foo/bar.pkl
does not resolve to itself.
Amending a Module
Recall how an object is amended:
pigeon {
name = "Pigeon"
diet = "Seeds"
}
parrot = (pigeon) { (1)
name = "Parrot" (2)
}
1 | Object parrot amends object pigeon , inheriting all of its members. |
2 | parrot overrides name . |
Amending a module works in the same way, except that the syntax differs slightly:
name = "Pigeon"
diet = "Seeds"
amends "pigeon.pkl" (1)
name = "Parrot" (2)
1 | Module parrot amends module pigeon , inheriting all of its members. |
2 | parrot overrides name . |
A module is amended by way of an amends clause, which consists of the keyword amends
followed by the module URI of the module to amend.
An amends clause comes after the module clause (if present) and before any import clauses:
module parrot
amends "pigeon.pkl"
import "other.pkl"
name = "Parrot"
At most one amends clause is permitted. A module cannot have both an amends clause and an extends clause.
An amending module has the same type (that is, module class) as the module it amends.
As a consequence, it cannot define new properties, methods, or classes, unless they are declared as local
.
In our example, this means that module parrot
can only define (and thus override) the property name
.
Spelling mistakes such as namw
are caught immediately, rather than accidentally defining a new property.
Amending is used to fill in template modules:
-
The template module defines which properties exist, their types, and what module output is desired (for example JSON indented with two spaces).
-
The amending module fills in property values as required, relying on the structure, defaults and validation provided by the template module.
-
The amending module is evaluated to produce the final result.
Template modules are often provided by third parties and served over HTTPS.
Extending a Module
Recall how a class is extended:
open class Pigeon { (1)
name = "Pigeon"
diet = "Seeds"
}
class Parrot extends Pigeon { (2)
name = "Parrot" (3)
diet = "Berries" (4)
extinct = false (5)
function say() = "Pkl is great!" (6)
}
1 | Class Pigeon is declared as open for extension. |
2 | Class Parrot extends Pigeon , inheriting all of its members. |
3 | Parrot overrides name . |
4 | Parrot overrides diet . |
5 | Parrot defines a new property named extinct . |
6 | Parrot defines a new function named say . |
Extending a module works in the same way, except that the syntax differs slightly:
open module pigeon (1)
name = "Pigeon"
diet = "Seeds"
1 | Module pigeon is declared as open for extension. |
extends "pigeon.pkl" (1)
name = "Parrot" (2)
diet = "Berries" (3)
extinct = false (4)
function say() = "Pkl is great!" (5)
1 | Module parrot extends module pigeon , inheriting all of its members. |
2 | parrot overrides name . |
3 | parrot overrides diet . |
4 | parrot defines a new property named extinct . |
5 | parrot defines a new function named say . |
A module is extended by way of an extends clause, which consists of the keyword extends
followed by the module URI of the module to extend.
The extends clause comes after the module clause (if present) and before any import clauses.
Only modules declared as open
can be extended.
module parrot
extends "pigeon.pkl"
import "other.pkl"
name = "Parrot"
diet = "Berries"
extinct = false
function say() = "Pkl is great!"
At most one extends clause is permitted. A module cannot have both an amends clause and an extends clause.
Extending a module implicitly defines a new module class that extends the original module’s class.
Importing a Module
A module import makes the imported module accessible to the importing module. A module is imported by way of either an import clause, or an import expression.
Import Clauses
An import clause consists of the keyword import
followed by the module URI of the module to import.
An import clause comes after module, amends and extends clauses (if present), and before the module body:
module parrot
amends "pigeon.pkl"
import "module1.pkl"
import "module2.pkl"
name = "Parrot"
Multiple import clauses are permitted.
A module import implicitly defines a new const local
property through which the imported module can be accessed.
(Remember that modules are regular objects.)
The name of this property, called import name, is constructed from the module URI as follows:
-
Strip the URI scheme, including the colon (
:
). -
Strip everything up to and including the last forward slash (
/
). -
Strip any trailing
.pkl
file extension.
Here are some examples:
import "modules/pigeon.pkl" // relative to current module
name = pigeon.name
import "https://mycompany.com/modules/pigeon.pkl"
name = pigeon.name
import "pkl:math"
pi = math.Pi
import "package://example.com/birds@1.0.0#/sparrow.pkl"
name = sparrow.name
Because its members are automatically visible in every module, the pkl:base
module is typically not imported.
Occasionally, the default import name for a module may not be convenient or appropriate:
-
If not a valid identifier, the import name needs to be enclosed in backticks on each use, for example,
`my-module`.someMember
. -
The import name may clash with other names in the importing module.
In such a case, a different import name can be chosen:
import "pigeon.pkl" as piggy
name = "Parrot"
diet = piggy.diet
What makes a good module file name?
When creating a new module, especially one intended for import into other modules, try to choose a module file name that makes a good import name:
|
Import Expressions (import()
)
An import expression consists of the keyword import
, followed by a module URI wrapped in parentheses:
module birds
pigeon = import("pigeon.pkl")
parrot = import("parrot.pkl")
Unlike import clauses, import expressions only import a value, and do not import a type. A type is a name that can be used in type positions, for example, as a type annotation.
Globbed Imports
Multiple modules may be imported at once with import*
.
When importing multiple modules, a glob pattern is used to match against existing resources.
A globbed import evaluates to a Mapping
, where keys are the expanded form of the glob and values are import expressions on each individual module.
Globbed imports can be expressed as either a clause or as an expression.
When expressed as a clause, they follow the same naming rules as a normal import clause: they introduce a local property equal to the last path segment without the .pkl
extension.
A globbed import clause cannot be used as a type.
import* "birds/*.pkl" as allBirds (1)
import* "reptiles/*.pkl" (2)
birds = import*("birds/*.pkl") (3)
1 | Globbed import clause |
2 | Globbed import clause without an explicit name (will import the name * ) |
3 | Globbed import expression |
Assuming that a file system contains these files:
.
├── birds/
│ ├── pigeon.pkl
│ ├── parrot.pkl
│ └── falcon.pkl
└── index.pkl
The following two snippets are logically identical:
birds = import*("birds/*.pkl")
birds = new Mapping {
["birds/pigeon.pkl"] = import("birds/pigeon.pkl")
["birds/parrot.pkl"] = import("birds/parrot.pkl")
["birds/falcon.pkl"] = import("birds/falcon.pkl")
}
By default, only the file
and package
schemes are globbable.
Globbing another scheme will cause Pkl to throw.
Pkl can be extended to provide custom globbable schemes through the ModuleKey SPI.
When globbing within packages, only the asset path (the fragment section) is globbable. Otherwise, characters are interpreted verbatim, and not treated as glob wildcards.
For details on how glob patterns work, refer to Glob Patterns in the Advanced Topics section.
When globbing files, symbolic links are not followed. Additionally, the . and .. entries are skipped.This behavior is similar to the behavior of Bash with shopt -s dotglob enabled.
|
Security Checks
When attempting to directly evaluate a module, as in pkl eval myModule.pkl
,
the following security checks are performed:
-
The module URI is checked against the module allowlist (
--allowed-modules
).
The module allowlist is a comma-separated list of regular expressions.
For access to be granted, at least one regular expression must match a prefix of the module URI.
For example, the allowlist file:,https:
grants access to any module whose URI starts with file:
or https:
.
When a module attempts to load another module (via amends
, extends
, or imports
),
the following security checks are performed:
-
The target module URI is checked against the module allowlist (
--allowed-modules
). -
The source and target modules' trust levels are determined and compared.
For access to be granted, the source module’s trust level must be higher than or equal to the target module’s trust level. By default, there are five trust levels, listed from highest to lowest:
-
repl:
modules (code evaluated in the REPL) -
file:
modules -
modulepath:
modules -
All other modules (for example
https:
) -
pkl:
modules (standard library)
For example, this means that file:
modules can import https:
modules, but not the other way around.
If a module URI is resolved in multiple steps, all URIs are subject to the above security checks. An example of this is an HTTPS URL that results in a redirect.
Pkl embedders can further customize security checks.
Module Output
By default, the output of evaluating a module is the entire module rendered as Pcf. There are two ways to change this behavior:
-
Outside the language, by using the
--format
CLI option or theoutputFormat
Gradle task property. -
Inside the language, by configuring a module’s
output
property.
CLI
Given the following module:
a = 10
b {
c = 20
}
pkl eval config.pkl
, which is shorthand for pkl eval --format pcf config.pkl
, renders the module as Pcf:
a = 10
b {
c = 20
}
pkl eval --format yaml config.pkl
renders the module as YAML:
a: 10
b:
c: 20
Likewise, pkl eval --format json config.pkl
renders the module as JSON.
In-language
Now let’s do the same — and more — inside the language.
Modules have an output property that controls what the module’s output is and how that output is rendered.
To control what the output is, set the output.value property:
a = 10
b {
c = 20
}
output {
value = b // defaults to `outer`, which is the entire module
}
This produces:
c = 20
To control how the output is rendered, set the output.renderer property:
a = 10
b {
c = 20
}
output {
renderer = new YamlRenderer {}
}
The standard library provides these renderers:
To render a format that is not yet supported, you can implement your own renderer by extending the class ValueRenderer.
The standard library renderers can be configured with value converters, which influence how particular values are rendered.
For example, since YAML does not have a standard way to represent data sizes, a plain YamlRenderer
cannot render DataSize
values.
However, we can teach it to:
quota {
memory = 100.mb
disk = 20.gb
}
output {
renderer = new YamlRenderer {
converters {
[DataSize] = (size) -> "\(size.value) \(size.unit)"
}
}
}
This produces:
quota:
memory: 100 MB
disk: 20 GB
In addition to type based converters, renderers also support path based converters:
output {
renderer = new YamlRenderer {
converters {
["quota.memory"] = (size) -> "\(size.value) \(size.unit)"
["quota.disk"] = (size) -> "\(size.value) \(size.unit)"
}
}
}
For more on path-based converters, see PcfRenderer.converters.
Sometimes it is useful to directly compute the final module output, bypassing output.value
and output.converters
.
To do so, set the output.text property to a String value:
output {
// defaults to `renderer.render(value)`
text = "this is the final output".toUpperCase()
}
This produces:
THIS IS THE FINAL OUTPUT
Multiple File Output
It is sometimes desirable for a single module to produce multiple output files.
This is possible by configuring a module’s output.files
property
and specifying the --multiple-file-output-path
(or -m
for short) CLI option.
Here is an example that produces a JSON and a YAML file:
pigeon {
name = "Pigeon"
diet = "Seeds"
}
parrot {
name = "Parrot"
diet = "Seeds"
}
output {
files {
["birds/pigeon.json"] {
value = pigeon
renderer = new JsonRenderer {}
}
["birds/parrot.yaml"] {
value = parrot
renderer = new YamlRenderer {}
}
}
}
Running pkl eval -m output/ birds.pkl
produces the following output files:
{
"name": "Pigeon",
"diet": "Seeds"
}
name: Parrot
diet: Berries
Within output.files
,
a key determines a file’s path relative to --multiple-file-output-path
,
and a value determines the file’s contents.
If a file’s path resolves to a location outside --multiple-file-output-path
,
evaluation fails with an error.
Non-existing parent directories are created.
Aggregating Module Outputs
A value within output.files
can be another module’s output
.
With this, a module can aggregate the outputs of multiple other modules.
Here is an example:
name = "Pigeon"
diet = "Seeds"
output {
renderer = new JsonRenderer {}
}
name = "Parrot"
diet = "Seeds"
output {
renderer = new YamlRenderer {}
}
import "pigeon.pkl"
import "parrot.pkl"
output {
files {
["birds/pigeon.json"] = pigeon.output
["birds/parrot.yaml"] = parrot.output
}
}
When aggregating module outputs, the appropriate file extensions can be obtained programmatically: birds.pkl
|
Null Values
The keyword null
indicates the absence of a value.
null
is an instance of Null, a direct subclass of Any
.
Non-Null Operator
The !!
(non-null) operator asserts that its operand is non-null.
Here are some examples:
name = "Pigeon"
nameNonNull = name!! (1)
name2 = null
name2NonNull = name2!! (2)
1 | result: "Pigeon" |
2 | result: Error: Expected a non-null value, but got null . |
Null Coalescing
The ??
(null coalescing) operator fills in a default for a null
value.
value ?? default
The above expression evaluates to value
if value
is non-null, and to default
otherwise.
Here are some examples:
name = "Pigeon"
nameOrParrot = name ?? "Parrot" (1)
name2 = null
name2OrParrot = name2 ?? "Parrot" (2)
1 | result: "Pigeon" |
2 | result: "Parrot" |
Default non-null behavior
Many languages allow For example, As per our example |
Null Propagation
The ?.
(null propagation) operator provides null-safe access to a member whose receiver may be null
.
value?.member
The above expression evaluates to value.member
if value
is non-null, and to null
otherwise.
Here are some examples:
name = "Pigeon"
nameLength = name?.length (1)
nameUpper = name?.toUpperCase() (2)
name2 = null
name2Length = name2?.length (3)
name2Upper = name2?.toUpperCase() (4)
1 | result: 6 |
2 | result: "PIGEON" |
3 | result: null |
4 | result: null |
The ?.
operator is often combined with ??
:
name = null
nameLength = name?.length ?? 0 (1)
1 | result: 0 |
ifNonNull Method
The ifNonNull() method is a generalization of the null propagation operator.
name.ifNonNull((it) -> doSomethingWith(it))
The above expression evaluates to doSomethingWith(name)
if name
is non-null, and to null
otherwise.
Here are some examples:
name = "Pigeon"
nameWithTitle = name.ifNonNull((it) -> "Dr." + it) (1)
name2 = null
name2WithTitle = name2.ifNonNull((it) -> "Dr." + it) (2)
1 | result: "Dr. Pigeon" |
2 | result: null |
NonNull Type Alias
To express that a property can have any type except Null
, use the NonNull
type alias:
x: NonNull
If Expressions
An if
expression serves the same role as the ternary operator (? :
) in other languages.
Every if
expression must have an else
branch.
num = if (2 + 2 == 5) 1984 else 42 (1)
1 | result: 42 |
Resources
Pkl programs can read external resources, such as environment variables or text files.
To read a resource, use a read
expression:
path = read("env:PATH")
By default, the following resource URI schemes are supported:
- env:
-
Reads an environment variable. Result type is
String
. - prop:
-
Reads an external property set via the
-p name=value
CLI option. Result type isString
. - file:
-
Reads a file from the file system. Result type is Resource.
- http(s):
-
Reads an HTTP(S) resource. Result type is Resource.
- modulepath:
-
Reads a resource from the module path (
--module-path
) or JVM class path. Result type is Resource. See Module Path URI for further information. - package:
-
Reads a resource from a package. Result type is Resource. See Package asset URI: for further information.
Relative resource URIs are resolved against the enclosing module’s URI.
Resources are cached in memory on the first read. Therefore, subsequent reads are guaranteed to return the same result.
Nullable Reads
If a resource does not exist or cannot be read, read()
fails with an error.
To recover from the absence of a resource, use read?()
instead,
which returns null
for absent resources:
port = read?("env:PORT")?.toInt() ?? 1234
Globbed Reads
Multiple resources may be read at the same time with read*()
.
When reading multiple resources, a glob pattern is used to match against existing resources.
A globbed read returns a Mapping
, where the keys are the expanded form of the glob, and values are read
expressions on each individual resource.
Assuming that a file system contains these files:
.
├── birds/
│ ├── pigeon.pkl
│ ├── parrot.pkl
│ └── falcon.pkl
└── index.pkl
The following two snippets are logically identical:
birdFiles = read*("birds/*.pkl")
birdFiles = new Mapping {
["birds/pigeon.pkl"] = read("birds/pigeon.pkl")
["birds/parrot.pkl"] = read("birds/parrot.pkl")
["birds/falcon.pkl"] = read("birds/falcon.pkl")
}
By default, the following schemes support globbing:
-
modulepath
-
file
-
env
-
prop
Globbing other resources results in an error.
For details on how glob patterns work, reference Glob Patterns in the Advanced Topics section.
When globbing files, symbolic links are not followed. Additionally, the . and .. entries are skipped.This behavior is similar to the behavior of Bash with shopt -s dotglob enabled.
|
The While globbing is traditionally viewed as a way to match elements in a file system, a glob pattern is simply a way to match strings. Thus, environment variables and external properties can be globbed, where their names get matched according to the rules described by the glob pattern. To match all values within these schemes, use the |
Extending resource readers
When Pkl is embedded within another runtime, it can be extended to read other kinds of resources.
When embedded into a JVM application, new resources may be read by implementing the ResourceReader SPI. When Pkl is embedded within Swift, new resources may be read by implementing the ResourceReader interface. When Pkl is embedded within Go, new resources may be read by implementing the ResourceReader interface.
Resource Allowlist
When attempting to read a resource, the resource URI is checked against the resource allowlist (--allowed-resources
).
In embedded mode, the allowlist is configured via an evaluator’s SecurityManager.
The resource allowlist is a comma-separated list of regular expressions.
For access to be granted, at least one regular expression must match a prefix of the resource URI.
For example, the allowlist file:,https:
grants access to any resource whose URI starts with file:
or https:
.
Errors
By design, errors are fatal in Pkl — there is no way to recover from them.
To raise an error, use a throw
expression:
myValue = throw("You won't be able to recover from this one!") (1)
1 | myValue never receives a value because the program exits. |
The error message is printed to the console and the program exits. In embedded mode, a PklException is thrown.
Debugging
When debugging Pkl code, it can be useful to print the value of an expression.
To do so, use a trace
expression:
num1 = 42
num2 = 16
res = trace(num1 * num2)
Tracing an expression does not affect its result, but prints both its source code and result on standard error:
pkl: TRACE: num1 * num2 = 672 (at file:///some/module.pkl, line 42)
Advanced Topics
This section discusses language features that are generally more relevant to template and library authors than template consumers.
Meaning of new
Let Expressions
Type Tests
Type Casts
Lists
Sets
Maps
Regular Expressions
Type Aliases
Type Annotations
Anonymous Functions
Amending Null Values
When Generators
For Generators
Spread Syntax
Member Predicates ([[…]]
)
Glob Patterns
Doc Comments
Name Resolution
Grammar Definition
Reserved Keywords
Blank Identifiers
Projects
Meaning of new
Objects in Pkl always amends some value.
The new
keyword is a special case of amending where a contextual value is amended.
In Pkl, there are two forms of new
objects:
-
new
with explicit type information, for example,new Foo {}
. -
new
without type information, for example,new {}
.
Type defaults
To understand instantiation cases without explicit parent or type information, it’s important to first understand implicit default values.
When a property is declared in a module or class but is not provided an explicit default value, the property’s default value becomes the type’s default value.
Similarly, when Listing
and Mapping
types are declared with explicit type arguments for their element or value, their default
property amends that declared type.
When Listing
and Mapping
types are declared without type arguments, their default
property amends an empty Dynamic
object.
Some types, including Pair
and primitives like String
, Number
, and Boolean
have no default value; attempting to render such a property results in the error "Tried to read property <name>
but its value is undefined".
class Bird {
name: String = "polly"
}
bird: Bird (1)
birdListing: Listing<Bird> (2)
birdMapping: Mapping<String, Bird> (3)
1 | Without an explicit default value, this property has default value new Bird { name = "polly" } |
2 | With an explicit element type argument, this property’s default value is equivalent to new Listing<Bird> { default = (_) → new Bird { name = "polly" } } |
3 | With an explicit value type argument, this property’s default value is equivalent to new Mapping<String, Bird> { default = (_) → new Bird { name = "polly" } } |
Explicitly Typed new
Instantiating an object with new <type>
results in a value that amends the specified type’s default value.
Notably, creating a Listing
element or assigning a Mapping
entry value with an explicitly typed new
ignores the object’s default
value.
class Bird {
/// The name of the bird
name: String
/// Whether this is a bird of prey or not.
isPredatory: Boolean?
}
newProperty = new Bird { (1)
name = "Warbler"
}
someListing = new Listing<Bird> {
default {
isPredatory = true
}
new Bird { (2)
name = "Sand Piper"
}
}
someMapping = new Mapping<String, Bird> {
default {
isPredatory = true
}
["Penguin"] = new Bird { (3)
name = "Penguin"
}
}
1 | Assigning a new explicitly-typed value to a property. |
2 | Adding an new explicitly-typed Listing element.
The value will not have property isPredatory = true as the default property of the Listing is not used. |
3 | Assigning a new explicitly-typed value to a Mapping entry.
The value will not have property isPredatory = true as the default property of the Mapping is not used. |
Implicitly Typed new
When using the implicitly typed new
invocation, there is no explicit parent value to amend.
In these cases, Pkl infers the amend operation’s parent value based on context:
-
When assigning to a declared property, the property’s default value is amended (including
null
). If there is no type associated with the property, an emptyDynamic
object is amended. -
When assigning to an entry (e.g. a
Mapping
member) or element (e.g. aListing
member), the enclosing object’sdefault
property is applied to the corresponding index or key, respectively, to produce the value to be amended. -
In other cases, evaluation fails with the error message "Cannot tell which parent to amend".
The type annotation of a method parameter is not used for inference. In this case, the argument’s type should be specified explicitly.
class Bird {
name: String
function listHatchlings(items: Listing<String>): Listing<String> = new {
for (item in items) {
"\(name):\(item)"
}
}
}
typedProperty: Bird = new { (1)
name = "Swift"
}
untypedProperty = new { (2)
hello = "world"
}
typedListing: Listing<Bird> = new {
new { (3)
name = "Kite"
}
}
untypedListing: Listing = new {
new { (4)
hello = "there"
}
}
typedMapping: Mapping<String, Bird> = new {
default { entryKey ->
name = entryKey
}
["Saltmarsh Sparrow"] = new { (5)
name = "Sharp-tailed Sparrow"
}
}
amendedMapping = (typedMapping) {
["Saltmarsh Sparrow"] = new {} (6)
}
class Aviary {
birds: Listing<Bird> = new {
new { name = "Osprey" }
}
}
aviary: Aviary = new {
birds = new { (7)
new { name = "Kiwi" }
}
}
swiftHatchlings = typedProperty.listHatchlings(new { "Poppy"; "Chirpy" }) (8)
1 | Assignment to a property with an explicitly declared type, amending new Bird {} . |
2 | Assignment to an undeclared property in module context, amending new Dynamic {} . |
3 | Listing element creation, amending implicit default , new Bird {} . |
4 | Listing element creation, amending implicit default , new Dynamic {} . |
5 | Mapping value assignment, amdending the result of applying default to "Saltmarsh Sparrow" , new Bird { name = "Saltmarsh Sparrow" } . |
6 | Mapping value assignment replacing the parent’s entry, amending the result of applying default to "Saltmarsh Sparrow" , new Bird { name = "Saltmarsh Sparrow" } . |
7 | Admending the property default value new Listing { new Bird { name = "Osprey" } } ; the result contains both birds. |
8 | Error: Cannot tell which parent to amend. |
Let Expressions
A let
expression is Pkl’s version of an (immutable) local variable.
Its syntax is:
let (<name> = <value>) <expr>
A let
expression is evaluated as follows:
-
<name>
is bound to<value>
, itself an expression. -
<expr>
is evaluated, which can refer to<value>
by its<name>
(this is the point). -
The result of <expr> becomes the result of the overall expression.
Here is an example:
birdDiets =
let (diets = List("Seeds", "Berries", "Mice"))
List(diets[2], diets[0]) (1)
1 | result: birdDiets = List("Mice", "Seeds") |
let
expressions serve two purposes:
-
They introduce a human-friendly name for a potentially complex expression.
-
They evaluate a potentially expensive expression that is used in multiple places only once.
let
expressions can have type annotations:
birdDiets =
let (diets: List<String> = List("Seeds", "Berries", "Mice"))
diets[2] + diets[0] (1)
1 | result: birdDiets = List("Mice", "Seeds") |
let
expressions can be stacked:
birdDiets =
let (birds = List("Pigeon", "Barn owl", "Parrot"))
let (diet = List("Seeds", "Mice", "Berries"))
birds.zip(diet) (1)
1 | result: birdDiets = List(Pair("Pigeon", "Seeds"), Pair("Barn owl", "Mice"), Pair("Parrot", "Berries")) |
Type Tests
To test if a value conforms to a type, use the is operator.
All the following tests hold:
test1 = 42 is Int
test2 = 42 is Number
test3 = 42 is Any
test4 = !(42 is String)
open class Base
class Derived extends Base
base = new Base {}
test5 = base is Base
test6 = base is Any
test7 = !(base is Derived)
derived = new Derived {}
test8 = derived is Derived
test9 = derived is Base
test10 = derived is Any
A value can be tested against any type, not just a class:
test1 = email is String(contains("@")) (1)
test2 = map is Map<Int, Base> (2)
test3 = name is "Pigeon"|"Barn owl"|"Parrot" (3)
1 | email is tested for being a string that contains a @ sign |
2 | map is tested for being a map from Int to Base values |
3 | name is tested for being one of "Pigeon" , "Barn owl" , or "Parrot" |
Type Casts
The as (type cast) operator performs a runtime type check on its operand. If the type check succeeds, the operand is returned as-is; otherwise, an error is thrown.
birds {
new { name = "Pigeon" }
new { name = "Barn owl" }
}
names = birds.toList().map((it) -> it.name) as List<String>
Although type casts are never mandatory in Pkl, they occasionally help humans and tools better understand an expression’s type.
Lists
A value of type List is an ordered, indexed collection of elements.
A list’s elements have zero-based indexes and are eagerly evaluated.
When to use List vs. Listing
Templates and schemas should almost always use listings instead of lists. Note that listings can be converted to lists when the need arises. |
Lists are constructed with the List()
method[5]:
list1 = List() (1)
list2 = List(1, 2, 3) (2)
list3 = List(1, "x", 5.min, List(1, 2, 3)) (3)
1 | result: empty list |
2 | result: list of length 3 |
3 | result: heterogeneous list whose last element is another list |
To concatenate lists, use the +
operator:
List(1, 2) + List(3, 4) + List(5)
To access a list element by index, use the []
(subscript) operator:
list = List(1, 2, 3, 4)
listElement = list[2] (1)
1 | result: 3 |
Class List
offers a rich API.
Here are just a few examples:
list = List(1, 2, 3, 4)
res1 = list.contains(3) (1)
res2 = list.first (2)
res3 = list.rest (3)
res4 = list.reverse() (4)
res5 = list.drop(1).take(2) (5)
res6 = list.map((n) -> n * 3) (6)
1 | result: true |
2 | result: 1 |
3 | result: List(2, 3, 4) |
4 | result: List(4, 3, 2, 1) |
5 | result: List(2, 3) |
6 | result: List(3, 6, 9, 12) |
Sets
A value of type Set is an ordered collection of unique elements.
A set’s elements are eagerly evaluated.
Sets are constructed with the Set()
method[5]:
res1 = Set() (1)
res2 = Set(1, 2, 3) (2)
res3 = Set(1, 2, 3, 1) (3)
res4 = Set(1, "x", 5.min, List(1, 2, 3)) (4)
1 | result: empty set |
2 | result: set of length 3 |
3 | result: same set of length 3 |
4 | result: heterogeneous set that contains a list as its last element |
To compute the union of sets, use the +
operator:
Set(1, 2) + Set(2, 3) + Set(5, 3) (1)
1 | result: Set(1, 2, 3, 5) |
Class Set
offers a rich API.
Here are just a few examples:
set = Set(1, 2, 3, 4)
res1 = set.contains(3) (1)
res2 = set.drop(1).take(2) (2)
res3 = set.map((n) -> n * 3) (3)
res4 = set.intersect(Set(3, 9, 2)) (4)
1 | result: true |
2 | result: Set(2, 3) |
3 | result: Set(3, 6, 9, 12) |
4 | result: Set(3, 2) |
Maps
A value of type Map is an ordered collection of values indexed by key.
A map’s key-value pairs are called its entries. Keys and values are eagerly evaluated.
When to use Map vs. Mapping
Templates and schemas should almost always use mappings instead of maps. (Note that mappings can be converted to maps when the need arises.) |
Maps are constructed by passing alternating keys and values to the Map()
method[5]:
map1 = Map() (1)
map2 = Map(1, "one", 2, "two", 3, "three") (2)
map3 = Map(1, "x", 2, 5.min, 3, Map(1, 2)) (3)
1 | result: empty map |
2 | result: set of length 3 |
3 | result: heterogeneous map whose last value is another map |
Any Pkl value can be used as a map key:
Map(new Dynamic { name = "Pigeon" }, 10.gb)
To merge maps, use the +
operator:
combinedMaps = Map(1, "one") + Map(2, "two", 1, "three") + Map(4, "four") (1)
1 | result: Map(1, "three", 2, "two", 4, "four") |
To access a value by key, use the []
(subscript) operator:
map = Map("Pigeon", 5.gb, "Parrot", 10.gb)
parrotValue = map["Parrot"] (1)
1 | result: 10.gb |
Class Map
offers a rich API.
Here are just a few examples:
map = Map("Pigeon", 5.gb, "Parrot", 10.gb)
res1 = map.containsKey("Parrot") (1)
res2 = map.containsValue(8.gb) (2)
res3 = map.isEmpty (3)
res4 = map.length (4)
res5 = map.getOrNull("Falcon") (5)
1 | result: true |
2 | result: false |
3 | result: false |
4 | result: 2 |
5 | result: null |
Regular Expressions
A value of type Regex is a regular expression with the same syntax and semantics as a Java regular expression.
Regular expressions are constructed with the Regex() method:
emailRegex = Regex(#"([\w\.]+)@([\w\.]+)"#)
Notice the use of custom string delimiters #"
and "#
, which change the string’s escape character from \
to \#
.
As a consequence, the regular expression’s backslash escape character no longer requires escaping.
To test if a string fully matches a regular expression, use String.matches():
"pigeon@example.com".matches(emailRegex)
Many String
methods accept either a String
or Regex
argument.
Here is an example:
res1 = "Pigeon<pigeon@example.com>".contains("pigeon@example.com")
res2 = "Pigeon<pigeon@example.com>".contains(emailRegex)
To find all matches of a regex in a string, use Regex.findMatchesIn(). The result is a list of RegexMatch objects containing details about each match:
matches = emailRegex.findMatchesIn("pigeon@example.com / falcon@example.com / parrot@example.com")
list1 = matches.drop(1).map((it) -> it.start) (1)
list2 = matches.drop(1).map((it) -> it.value) (2)
list3 = matches.drop(1).map((it) -> it.groups[1].value) (3)
1 | result: List(0, 19, 40) (the entire match, matches[0] , was dropped) |
2 | result: List("pigeon@example.com", "falcon@example.com", "parrot@example.com") |
3 | result: List("pigeon, falcon, parrot") |
Type Aliases
A type alias introduces a new name for a (potentially complicated) type:
typealias EmailAddress = String(matches(Regex(#".+@.+"#)))
Once a type alias has been defined, it can be used in type annotations:
email: EmailAddress = "pigeon@example.com"
emailList: List<EmailAddress> = List("pigeon@example.com", "parrot@example.com")
New type aliases can be defined in terms of existing ones:
typealias EmailList = List<EmailAddress>
emailList: EmailList = List("pigeon@example.com", "parrot@example.com")
Type aliases can have type parameters:
typealias StringMap<Value> = Map<String, Value>
map: StringMap<Int> = Map("Pigeon", 42, "Falcon", 21)
Code generators have different strategies for dealing with type aliases:
Type aliases for unions of String Literal Types are turned into enum classes by both code generators.
Predefined Type Aliases
The pkl.base module defines the following type aliases:
-
Uri (any String value)
Note that UInt has the same maximum value as Int , half of what would normally be expected.
|
The main purpose of the provided integer aliases is to enforce the range of an integer:
port: UInt16 = -1
This gives:
Type constraint isBetween(0, 65535) violated.
Value: -1
To restrict a number to a custom range, use the isBetween method:
port: Int(isBetween(0, 1023)) = 443
Remember that numbers are always instances of Int or Float .
Type aliases such as UInt16 only check that numbers are within a certain range.
|
Type Annotations
Property and method definitions may optionally contain type annotations. Type annotations serve the following purposes:
-
Documentation + Type annotations help to document data models. They are included in any generated documentation.
-
Validation + Type annotations are validated at runtime.
-
Defaults + Type-annotated properties have Default Values.
-
Code Generation + Type annotations enable statically typed access to configuration data through code generation.
-
Tooling + Type annotations enable advanced tooling features such as code completion in editors.
Class Types
Any class can be used as a type:
class Bird {
name: String (1)
}
bird: Bird (2)
1 | Declares an instance property of type String . |
2 | Declares a module property of type Bird . |
Module Types
Any module import can be used as type:
name: String
lifespan: Int
import "bird.pkl"
pigeon: bird (1)
parrot: bird (1)
1 | Guaranteed to amend bird.pkl. |
As a special case, the module
keyword denotes the enclosing module’s type:
name: String
lifespan: Int
friends: Listing<module>
amends "bird.pkl"
name = "Pigeon"
lifespan = 8
friends {
import("falcon.pkl") (1)
}
1 | falcon.pkl (not shown here) is guaranteed to amend bird.pkl. |
Type Aliases
Any type alias can be used as a type:
typealias EmailAddress = String(contains("@"))
email: EmailAddress (1)
emailList: List<EmailAddress> (2)
1 | equivalent to email: String(contains("@")) for type checking purposes |
2 | equivalent to emailList: List<String(contains("@"))> for type checking purposes |
Nullable Types
Class types such as Bird
(see above) do not admit null
values.
To turn them into nullable types, append a question mark (?
):
bird: Bird = null (1)
bird2: Bird? = null (2)
1 | throws Type mismatch: Expected a value of type Bird, but got null |
2 | succeeds |
The only class types that admit null
values despite not ending in ?
are Any
and Null
.
(Null
is not very useful as a type because it only admits null
values.)
Any?
and Null?
are equivalent to Any
and Null
, respectively.
In some languages, nullable types are also known as optional types.
Generic Types
The following class types are generic types:
-
Pair
-
Collection
-
Listing
-
List
-
Mapping
-
Set
-
Map
-
Function0
-
Function1
-
Function2
-
Function3
-
Function4
-
Function5
-
Class
A generic type has constituent types written in angle brackets (<>
):
pair: Pair<String, Bird> (1)
coll: Collection<Bird> (2)
list: List<Bird> (3)
set: Set<Bird> (4)
map: Map<String, Bird> (5)
mapping: Mapping<String, Bird> (6)
1 | a pair String and Bird as types for the first and second element, respectively |
2 | a collection of Bird elements |
3 | a list of Bird elements |
4 | a set of Bird elements |
5 | a map with String keys and Bird values |
6 | a mapping of String keys and Bird values |
Omitting the constituent types is equivalent to declaring them as unknown
:
pair: Pair // equivalent to `Pair<unknown, unknown>`
coll: Collection // equivalent to `Collection<unknown>`
list: List // equivalent to `List<unknown>`
set: Set // equivalent to `Set<unknown>`
map: Map // equivalent to `Map<unknown, unknown>`
mapping: Mapping // equivalent to `Mapping<unknown, unknown>`
The unknown
type is both a top and a bottom type.
When a static type analyzer encounters an expression of unknown
type,
it backs off and trusts the user that they know what they are doing.
Union Types
A value of type A | B
, read "A or B", is either a value of type A
or a value of type B
.
class Bird { name: String }
bird1: String|Bird = "Pigeon"
bird2: String|Bird = new Bird { name = "Pigeon" }
More complex union types can be formed:
foo: List<Boolean|Number|String>|Bird
Union types have no implicit default values, but an explicit type can be chosen using a *
marker:
foo: "a"|"b" // undefined. Will throw an error if not amended
bar: "a"|*"b" // default value will be taken from type "b"
baz: "a"|"b" = "a" // explicit value is given
qux: String|*Int // default taken from Int, but Int has no default. Will throw if not amended
Union types often come in handy when writing schemas for legacy JSON or YAML files.
String Literal Types
A string literal type admits a single string value:
diet: "Seeds"
While occasionally useful on their own, string literal types are often combined with Union Types to form enumerated types:
diet: "Seeds"|"Berries"|"Insects"
To reuse an enumerated type, introduce a type alias:
typealias Diet = "Seeds"|"Berries"|"Insects"
diet: Diet
The Java and Kotlin code generators turn type aliases for enumerated types into enum classes.
Nothing Type
The nothing
type is the bottom type of Pkl’s type system, the counterpart of top type Any
.
The bottom type is assignment-compatible with every other type, and no other type is assignment-compatible with it.
Being assignment-compatible with every other type may sound too good to be true, but there is a catch — the nothing
type has no values!
Despite being a lonely type, nothing
has practical applications.
For example, it is used in the standard library’s TODO()
method:
function TODO(): nothing = throw("TODO")
A nothing
return type indicates that a method never returns normally but always throws an error.
Unknown Type
The unknown
type [6] is nothing
's even stranger cousin: it is both a top and bottom type!
This makes unknown
assignment-compatible with every other type, and every other type assignment-compatible with unknown
.
When a static type analyzer encounters a value of unknown
type,
it backs off and trusts the code’s author to know what they are doing — for example, whether a method called on the value exists.
Progressive Disclosure
In the spirit of progressive disclosure, type annotations are optional in Pkl.
Omitting a type annotation is equivalent to specifying the type unknown
:
lifespan = 42 (1)
map: Map (2)
function say(name) = name (3)
1 | shorthand for lifespan: unknown = 42
(As a dynamically typed language, Pkl does not try to statically infer types.) |
2 | shorthand for map: Map<unknown, unknown> = Map() |
3 | shorthand for function say(name: unknown): unknown = name |
Default Values
Type-annotated properties have implicit "empty" default values depending on their type:
class Bird
coll: Collection<Bird> // = List() (1)
list: List<Bird> // = List() (2)
set: Set<Bird> // = Set() (3)
map: Map<String, Bird> // = Map() (4)
listing: Listing<Bird> // = new Listing { default = (index) -> new Bird {} } (5)
mapping: Mapping<String, Bird> // = new Mapping { default = (key) -> new Bird {} } (6)
obj: Bird // = new Bird {} (7)
nullable: Bird? // = Null(new Bird {}) (8)
union: *Bird|String // = new Bird {} (9)
stringLiteral: "Pigeon" // = "Pigeon" (10)
nullish: Null // = null (11)
1 | Properties of type Collection default to the empty list. |
2 | Properties of type List default to the empty list. |
3 | Properties of type Set default to the empty set. |
4 | Properties of type Map default to the empty map. |
5 | Properties of type Listing<X> default to an empty listing whose default element is the default for X . |
6 | Properties of type Mapping<X, Y> default to an empty mapping whose default value is the default for Y . |
7 | Properties of non-external class type X default to new X {} . |
8 | Properties of type X? default to Null(x) where x is the default for X . |
9 | Properties with a union type have no default value. By prefixing one of the types in a union with a * , the default of that type is chosen as the default for the union. |
10 | Properties with a string literal type default to the type’s only value. |
11 | Properties of type Null default to null . |
See Amending Null Values for further information.
Properties of the following types do not have implicit default values:
-
abstract
classes, includingAny
andNotNull
-
Union types, unless an explicit default is given by prefixing one of the types with
*
. -
external
(built-in) classes, including:-
String
-
Boolean
-
Int
-
Float
-
Duration
-
DataSize
-
Pair
-
Regex
-
Accessing a property that neither has an (implicit or explicit) default value nor has been overridden throws an error:
name: String
Type Constraints
A type may be followed by a comma-separated list of type constraints enclosed in round brackets (()
).
A type constraint is a boolean expression that must hold for the annotated element.
Type constraints enable advanced runtime validation that goes beyond the capabilities of static type checking.
class Bird {
name: String(length >= 3) // (1)
parent: String(this != name) // (2)
}
pigeon: Bird = new {
name = "Pigeon"
parent = "Pigeon Sr." (3)
}
1 | Restricts name to have at least three characters. |
2 | The name of the bird (this ) should not be the same as the name of the parent . |
3 | Note how parent is different from name . If they were the same, we would be thrown a constraint error. |
In the following example, we define a Bird
with a name of only two characters.
pigeon: Bird = new {
// fails the constraint because [name] is less than 3 characters
name = "Pi"
}
Boolean expressions are convenient for ad-hoc type constraints. Alternatively, type constraints can be given as lambda expressions accepting a single argument, namely the value to be validated. This allows for the abstraction and reuse of type constraints.
class Project {
local emailAddress = (str) -> str.matches(Regex(#".+@.+"#))
email: String(emailAddress)
}
project: Project = new {
email = "projectPigeon@example.com"
}
project: Project = new {
// fails the constraint because `"projectPigeon-example.com"` doesn't match the regular expression.
email = "projectPigeon-example.com"
}
Composite Type Constraints
A composite type can have type constraints for the overall type, its constituent types, or both.
class Project {
local emailAddress = (str) -> str.matches(Regex(#".+@.+"#))
// constrain the nullable type's element type
type: String(contains("source"))?
// constrain the map type and its key/value types
contacts: Map<String(!isEmpty), String(emailAddress)>(length <= 5)
}
project: Project = new {
type = "open-source"
contacts = Map("Pigeon", "pigeon@example.com")
}
Anonymous Functions
An anonymous function is a function without a name.
Most modern general-purpose programming languages support anonymous functions, under names such as lamba expressions, arrow functions, function literals, closures, or procs.
Anonymous functions have their own literal syntax:
() -> expr (1)
(param) -> expr (2)
(param1, param2, ..., paramN) -> expr (3)
1 | Zero-parameter lambda expression |
2 | Single-parameter lambda expression |
3 | Multi-parameter lambda expression |
Here is an example:
(n) -> n * 3
This anonymous function accepts a parameter named n
, multiplies it by 3, and returns the result.
Anonymous functions are values of type Function, more specifically Function0, Function1, Function2, Function3, Function4, or Function5. They cannot have more than five parameters.
To invoke an anonymous function, call its apply method:
((n) -> n * 3).apply(4) // 12
Many standard library methods accept anonymous functions:
List(1, 2, 3).map((n) -> n * 3) // List(3, 6, 9)
Anonymous functions can be assigned to properties, thereby giving them a name:
add = (a, b) -> a + b
added = add.apply(2, 3)
If an anonymous function is not intended to be passed as value, it is customary to declare a method instead:
|
An anonymous function’s parameters can have type annotations:
(a: Number, b: Number) -> a + b
Applying this function to arguments not of type Number
results in an error.
Anonymous functions are closures: They can access members defined in a lexically enclosing scope, even after leaving that scope:
a = 42
addToA = (b: Number) -> a + b
list = List(1, 2, 3).map(addToA) // List(43, 44, 45)
Single-parameter anonymous functions can also be applied with the |>
(pipe) operator,
which expects a function argument to the left and an anonymous function to the right.
The pipe operator works especially well for chaining multiple functions:
mul3 = (n) -> n * 3
add2 = (n) -> n + 2
num = 4
|> mul3
|> add2
|> mul3 (1)
1 | result: 42 |
Like methods, anonymous functions can be recursive:
factor = (n: Number(isPositive)) -> if (n < 2) n else n * factor.apply(n - 1)
num = factor.apply(5) (1)
1 | result: 120 |
Mixins
A mixin is an anonymous function used to apply the same modification to different objects.
Even though mixins are regular functions, they are best created with object syntax:
withDiet = new Mixin {
diet = "Seeds"
}
Mixins can optionally specify which type of object they apply to:
class Bird { diet: String }
withDietTyped = new Mixin<Bird> {
diet = "Seeds"
}
For properties with type annotation, the shorthand new { … }
syntax can be used:
withDietTyped: Mixin<Bird> = new {
diet = "Seeds"
}
To apply a mixin, use the |>
(pipe) operator:
pigeon {
name = "Pigeon"
}
pigeonWithDiet = pigeon |> withDiet
barnOwl {
name = "Barn owl"
}
barnOwlWithDiet = barnOwl |> withDiet
withDiet
can be generalized by turning it into a factory method for mixins:
function withDiet(_diet: String) = new Mixin {
diet = _diet
}
seedPigeon = pigeon |> withDiet("Seeds")
MiceBarnOwl = barnOwl |> withDiet("Mice")
Mixins can themselves be modified with function amending.
Function Amending
An anonymous function that returns an object can be amended with the same syntax as that object. The result is a new function that accepts the same number of parameters as the original function, applies the original function to them, and amends the returned object.
Function amending is a special form of function composition. Thanks to function amending, Listing.default and Mapping.default can be treated as if they were objects, only gradually revealing their true (single-parameter function) nature:
birds = new Mapping {
default { (1)
diet = "Seeds"
}
["Pigeon"] { (2)
lifespan = 8
}
}
1 | Amends the default function, which returns a default mapping value given a mapping key, and sets property diet . |
2 | Implicitly applies the amended default function and amends the returned object with property lifespan . |
The result is a mapping whose entry "Pigeon"
has both diet
and lifespan
set.
When amending an anonymous function, it is possible to access its parameters
by declaring a comma-separated, arrow (→
) terminated parameter list after the opening curly brace ({
).
Once again, this is especially useful to configure a listing’s or mapping’s default
function:
birds = new Mapping {
default { key -> (1)
name = key
}
["Pigeon"] {} (2)
["Barn owl"] {} (3)
}
1 | Amends the default function and sets the name property to the mapping entry’s key.
To access the default function’s key parameter, it is declared with key → .
(Any other parameter name could be chosen, but key is customary for default functions.) |
2 | Defines a mapping entry with key "Pigeon" |
3 | Defines a mapping entry with key "Barn owl" |
The result is a mapping with two entries "Pigeon"
and "Barn owl"
whose name
properties are set to their keys.
Function amending can also be used to refine mixins.
Amending Null Values
It’s time to lift a secret: The predefined null
value is just one of the potentially many values of type Null
.
First, here are the technical facts:
-
Null values are constructed with
pkl.base#Null()
. -
Null(x)
constructs a null value that is equivalent tox
when amended. In other words,Null(x) { … }
is equivalent tox { … }
. -
All null values are equal according to
==
.
We say that Null(x)
is a "null value with default x".
But what is it useful for?
Null values with default are used to define properties that are null ("switched off") by default but have a default value once amended ("switched on").
Here is an example:
// we don't have a pet yet, but already know that it is going to be a bird
pet = Null(new Dynamic {
animal = "bird"
})
amends "template.pkl"
// We got a pet, let's fill in its name
pet {
name = "Parry the Parrot"
}
A null value can be switched on without adding or overriding a property:
amends "template.pkl"
// We do not need to name anything if we have no pet yet
pet {}
The predefined null
value is defined as Null(new Dynamic {})
.
In other words, amending null
is equivalent to amending Dynamic {}
(the empty dynamic object):
pet = null
pet {
name = "Parry the Parrot"
}
In most cases, the Null()
method is not used directly.
Instead, it is used under the hood to create implicit defaults for properties with nullable type:
class Pet {
name: String
animal: String = "bird"
}
// defaults to `Null(Pet {})`
pet: Pet?
amends "template.pkl"
pet {
name = "Perry the Parrot"
}
The general rule is: A property with nullable type X?
defaults to Null(x)
if type X
has default value x
, and to null
if X
has no default value.
When Generators
when
generators conditionally generate object members.
They come in two variants:
-
when (<condition>) { <members> }
-
when (<condition>) { <members> } else { <members> }
The following code conditionally generates properties hobby
and idol
:
isSinger = true
parrot {
lifespan = 20
when (isSinger) {
hobby = "singing"
idol = "Frank Sinatra"
}
}
when
generators can have an else
part:
isSinger = false
parrot {
lifespan = 20
when (isSinger) {
hobby = "singing"
idol = "Aretha Franklin"
} else {
hobby = "whistling"
idol = "Wolfgang Amadeus Mozart"
}
}
Besides properties, when
generators can generate elements and entries:
abilities {
"chirping"
when (isSinger) {
"singing" (1)
}
"whistling"
}
abilitiesByBird {
["Barn owl"] = "hooing"
when (isSinger) {
["Parrot"] = "singing" (2)
}
["Parrot"] = "whistling"
}
1 | conditional element |
2 | conditional entry |
For Generators
for
generators generate object members in a loop.
They come in two variants:
-
for (<value> in <iterable>) { <members> }
-
for (<key>, <value> in <iterable>) { <members> }
The following code generates a birds
object containing three elements.
Each element is an object with properties name
and lifespan
.
names = List("Pigeon", "Barn owl", "Parrot")
birds {
for (_name in names) {
new {
name = _name
lifespan = 42
}
}
}
The following code generates a birdsByName
object containing three entries.
Each entry is an object with properties name
and lifespan
keyed by name.
namesAndLifespans = Map("Pigeon", 8, "Barn owl", 15, "Parrot", 20)
birdsByName {
for (_name, _lifespan in namesAndLifespans) {
[_name] {
name = _name
lifespan = _lifespan
}
}
}
The following types are iterable:
Type | Key | Value |
---|---|---|
|
element index ( |
element value ( |
|
element index ( |
element value ( |
|
element index ( |
element value ( |
|
entry key ( |
entry value ( |
|
element index ( |
element value ( |
|
entry key ( |
entry value ( |
|
element index ( |
element value |
Indices are zero-based.
Note that for
generators can generate elements and entries but not properties.[7]
Spread Syntax (...
)
Spread syntax generates object members from an iterable value.
There are two variants of spread syntax, a non-nullable variant and a nullable variant.
-
...<iterable>
-
...?<iterable>
Spreading an Object
(one of Dynamic
, Listing
and Mapping
) will unpack all of its members into the enclosing object [8].
Entries become entries, elements become elements, and properties become properties.
entries1 {
["Pigeon"] = "Piggy the Pigeon"
["Barn owl"] = "Barney the Barn owl"
}
entries2 {
...entries1 (1)
}
elements1 { 1; 2 }
elements2 {
...elements1 (2)
}
properties1 {
name = "Pigeon"
diet = "Seeds"
}
properties2 {
...properties1 (3)
}
1 | Spreads entries ["Pigeon"] = "Piggy the Pigeon" and ["Barn owl"] = "Barney the Barn owl" |
2 | Spreads elements 1 and 2 |
3 | Spreads properties name = "Pigeon" and diet = "Seeds" |
Spreading all other iterable types generates members determined by the iterable. The following table describes how different iterables turn into object members:
Iterable type | Member type |
---|---|
|
Entry |
|
Element |
|
Element |
|
Element |
These types can only be spread into enclosing objects that support that member type.
For example, a List
can be spread into a Listing
, but cannot be spread into a Mapping
.
In some ways, spread syntax can be thought of as a shorthand for a for generator. One key difference is that spread syntax can generate properties, which is not possible with a for generator.
Look out for duplicate key conflicts when using spreads. Spreading entries or properties may cause conflicts due to matched existing key definitions. In the following code snippet,
|
Nullable spread
A non-nullable spread (...
) will error if the value being spread is null
.
In contrast, a nullable spread (...?
) is syntactic sugar for wrapping a spread in a when
.
The following two snippets are logically identical.
result {
...?myValue
}
result {
when (myValue != null) {
...myValue
}
}
Member Predicates ([[…]]
)
Occasionally it is useful to configure all object members matching a predicate. This is especially true when configuring elements, which—unlike entries—cannot be accessed by key:
environmentVariables { (1)
new { name = "PIGEON"; value = "pigeon-value" }
new { name = "PARROT"; value = "parrot-value" }
new { name = "BARN OWL"; value = "barn-owl-value" }
}
updated = (environmentVariables) {
[[name == "PARROT"]] { (2)
value = "new-value" (3)
}
}
1 | a listing of environment variables |
2 | amend element(s) whose name equals "PARROT" ( name is shorthand for this.name ) |
3 | update value to "new-value" |
The predicate, enclosed in double brackets (\[[…]]
), is matched against each member of the enclosing object.
Within the predicate, this
refers to the member that the predicate is matched against.
Matching members are amended ({ … }
) or overridden (= <new-value>
).
Glob Patterns
Resources and modules may be imported at the same time by globbing with the Globbed Imports and Globbed Reads features.
Pkl’s glob patterns mostly follow the rules described by glob(7), with the following differences:
-
*
includes names that start with a dot (.
). -
**
behaves like*
, except it also matches directory boundary characters (/
). -
Named character classes are not supported.
-
Collating symbols are not supported.
-
Equivalence class expressions are not supported.
-
Support for sub-patterns (patterns within
{
and}
) are added.
Here is a full specification of how globs work:
Wildcards
The following tokens denote wildcards:
Wildcard | Meaning |
---|---|
|
Match zero or more characters, until a directory boundary ( |
|
Match zero or more characters, crossing directory boundaries. |
|
Match a single character. |
|
Match a single character represented by this character class. |
Unlike globs within shells, the * wildcard includes names that start with a dot (. ).
|
Character Classes
Character classes are sequences delimited by the [
and ]
characters, and represent a single
character as described by the sequence within the enclosed brackets.
For example, the pattern [abc]
means "a single character that is a, b, or c".
Character classes may be negated using !
.
For example, the pattern [!abc]
means "a single character that is not a, b, nor c".
Character classes may use the -
character to denote a range.
The pattern [a-f]
is equivalent to [abcdef]
.
If the -
character exists at the beginning or the end of a character class, it does not carry any special meaning.
Within a character class, the characters {
, }
, \
, *
, and ?
do not have any special meaning.
A character class is not allowed to be empty.
Thus, if the first character within the character class is ]
, it is treated literally and not as the closing delimiter of the character class.
For example, the glob pattern []abc]
matches a single character that is either ]
, a
, b
, or c
.
Sub-patterns
Sub-patterns are glob patterns delimited by the {
and }
characters, and separated by the ,
character.
For example, the pattern {pigeon,parrot}
will match either pigeon
or parrot
.
Sub-patterns cannot be nested. The pattern {foo,{bar,baz}}
is not a valid glob pattern, and an error will be thrown during evaluation.
Escapes
The escape character (\
) can be used to remove the special meaning of a character. The following escapes are valid:
-
\[
-
\*
-
\?
-
\\
-
\{
All other escapes are considered a syntax error and an error is thrown.
If incorporating escape characters into a glob pattern, use custom string delimiters to express the glob pattern. For example, import*(#"\{foo.pkl"#) . This way, the backslash is interpreted as a backslash and not a string escape.
|
Examples
Pattern | Description |
---|---|
|
Anything suffixed by |
|
Anything suffixed by either |
|
Anything within the |
|
Anything starting with |
|
All Pkl files in the module path. |
Quoted Identifiers
An identifier is the name part of an entity in Pkl.
Entities that are named by identifiers include classes, properties, typealiases, and modules.
For example, class Bird
has the identifier Bird
.
Normally, an identifier must conform to Unicode’s UAX31-R1-1 syntax, with the additions of _
and $
permitted as identifier start characters.
Additionally, an identifier cannot clash with a keyword.
To define an identifier that is otherwise illegal, enclose them in backticks. This is called a quoted identifier.
`A Bird's First Flight Time` = 5.s
Backticks are not part of a quoted identifier’s name, and surrounding an already legal identifier with backticks is redundant.
|
Doc Comments
Doc comments are the user-facing documentation of a module and its members.
They consist of one or more lines starting with a triple slash (///
).
Here is a doc comment for a module:
/// An aviated animal going by the name of [bird](https://en.wikipedia.org/wiki/Bird).
///
/// These animals live on the planet Earth.
module com.animals.Birds
Doc comments are written in Markdown. The following Markdown features are supported:
-
all CommonMark features
Plaintext URLs are only rendered as links when enclosed in angle brackets:
|
Doc comments are consumed by humans reading source code, the Pkldoc documentation generator, code generators, and editor/IDE plugins. They are programmatically accessible via the pkl.reflect Pkl API and ModuleSchema Java API.
Doc Comment Style Guidelines
|
Doc comments can be attached to module, class, type alias, property, and method declarations. Here is a comprehensive example:
/// An aviated animal going by the name of [bird](https://en.wikipedia.org/wiki/Bird).
///
/// These animals live on the planet Earth.
module com.animals.Birds
/// A bird living on Earth.
///
/// Has [name] and [lifespan] properties and an [isOlderThan()] method.
class Bird {
/// The name of this bird.
name: String
/// The lifespan of this bird.
lifespan: UInt8
/// Tells if this bird is older than [bird].
function isOlderThan(bird: Bird): Boolean = lifespan > bird.lifespan
}
/// An adult [Bird].
typealias Adult = Bird(lifespan >= 2)
/// A common [Bird] found in large cities.
pigeon: Bird = new {
name = "Pigeon"
lifespan = 8
}
/// Creates a [Bird] with the given [_name] and lifespan `0`.
function Infant(_name: String): Bird = new { name = _name; lifespan = 0 }
Member Links
To link to a member declaration, write the member’s name enclosed in square brackets ([]
):
/// A common [Bird] found in large cities.
To customize the link text, insert the desired text, enclosed in square brackets, before the member name:
/// A [common Bird][Bird] found in large cities.
Custom link text can use markup:
/// A [*common* Bird][Bird] found in large cities.
The short link [Bird]
is equivalent to [`Bird`][Bird]
.
Member links are resolved according to Pkl’s normal name resolution rules. The syntax for linking to the members of Birds.pkl (see above) is as follows:
- Module
-
-
[module]
(from same module) -
[Birds]
(from a module that containsimport "Birds.pkl"
)
-
- Class
-
-
[Bird]
(from same module) -
[Birds.Bird]
(from a module that containsimport "Birds.pkl"
)
-
- Type Alias
-
-
[Adult]
(from same module) -
[Birds.Adult]
(from a module that containsimport "Birds.pkl"
)
-
- Class Property
-
-
[name]
(from same class) -
[Bird.name]
(from same module) -
[Birds.Bird.name]
(from a module that containsimport "Birds.pkl"
)
-
- Class Method
-
-
[greet()]
(from same class) -
[Bird.greet()]
(from same module) -
[Birds.Bird.greet()]
(from a module that containsimport "Birds.pkl"
)
-
- Class Method Parameter
-
-
[bird]
(from same method)
-
- Module Property
-
-
[pigeon]
(from same module) -
[Birds.pigeon]
(from a module that containsimport "Birds.pkl"
)
-
- Module Method
-
-
[isPigeon()]
(from same module) -
[Birds.isPigeon()]
(from a module that containsimport "Birds.pkl"
)
-
- Module Method Parameter
-
-
[bird]
(from same method)
-
Members of pkl.base
can be linked to by their simple name:
/// Returns a [String].
Module-level members can be prefixed with module.
to resolve name conflicts:
/// See [module.pigeon].
To exclude a member from documentation and code completion, annotate it with @Unlisted
:
@Unlisted
pigeon: Bird
The following member links are marked up as code but not rendered as links:[9]
-
[null]
,[true]
,[false]
,[this]
,[unknown]
,[nothing]
-
self-links
-
subsequent links to the same member from the same doc comment
-
links to a method’s own parameters
Nevertheless, it is a good practice to use member links in the above cases.
Name Resolution
Consider this snippet of code buried deep inside a config file:
a = x + 1
The call site’s "variable" syntax reveals that x
refers to a LAMP
(let binding, anonymous function parameter, method parameter, or property) definition. But which one?
To answer this question, Pkl follows these steps:
-
Search the lexically enclosing scopes of
x
, starting with the scope in whichx
occurs and continuing outwards up to and including the enclosing module’s top-level scope, for a LAMP definition namedx
. If a match is found, this is the answer. -
Search the
pkl.base
module for a top-level definition of propertyx
. If a match is found, this is the answer. -
Search the prototype chain of
this
, from bottom to top, for a definition of propertyx
. If a match is found, this is the answer. -
Throw a "name
x
not found" error.
Pkl’s LAMP name resolution is inspired by Newspeak.
The goal is for name resolution to be stable with regard to changes in external modules.
This is why lexically enclosing scopes are searched before the prototype chain of this ,
and why the prototype chains of lexically enclosing scopes are not searched,
which sometimes requires the use of outer. or module. .
For name resolution to fully stabilize, the list of top-level properties defined in pkl.base needs to be freezed.
This is tentatively planned for Pkl 1.0.
|
Consider this snippet of code buried deep inside a config file:
a = x("foo") + 1
The call site’s method call syntax reveals that x
refers to a method definition. But which one?
To answer this question, Pkl follows these steps:
-
Search the call sites' lexically enclosing scopes, starting with the scope in which the call site occurs and continuing outwards up to and including the enclosing module’s top-level scope, for a definition of method
x
. If a match is found, this is the answer. -
Search the
pkl.base
module for a top-level definition of methodx
. If a match is found, this is the answer. -
Search the class inheritance chain of
this
, starting with the class ofthis
and continuing upwards until and including classAny
, for a method namedx.
If a match is found, this is the answer. -
Throw a "method
x
not found" error.
Pkl does not support arity or type-based method overloading. Hence, the argument list of a method call is irrelevant for method resolution. |
Prototype Chain
Pkl’s object model is based on prototypical inheritance.
The prototype chain of object[10] x
contains, from bottom to top:
-
The chain of objects amended to create
x
, ending inx
itself, in reverse order.[11] -
The prototype of the class of the top object in (1). If no amending took place, this is the class of
x
. -
The prototypes of the superclasses of (2).
The prototype of class X
is an instance of X
that defines the defaults for properties defined in X
.
Its direct ancestor in the prototype chain is the prototype of the superclass of X
.
The prototype of class Any
sits at the top of every prototype chain.
To reduce the chance of naming collisions, Any
does not define any property names.[12]
Consider the following code:
one = new Dynamic { name = "Pigeon" }
two = (one) { lifespan = 8 }
The prototype chain of object two
contains, now listed from top to bottom:
-
The prototype of class
Any
. -
The prototype of class
Dynamic
. -
one
-
two
Consider the following code:
abstract class Named {
name: String
}
class Bird extends Named {
lifespan: Int = 42
}
one = new Bird { name = "Pigeon" }
two = (one) { lifespan = 8 }
The prototype chain of object two
contains, listed from top to bottom:
-
The prototype of class
Any
. -
The prototype of class
Typed
. -
The prototype of class
Named
. -
The prototype of class
Bird
. -
one
-
two
Non-object Values
The prototype chain of non-object value x
contains, from bottom to top:
-
The prototype of the class of
x
. -
The prototypes of the superclasses of (1).
For example, the prototype chain of value 42
contains, now listed from top to bottom:
-
The prototype of class
Any
. -
The prototype of class
Number
. -
The prototype of class
Int
.
A prototype chain never contains a non-object value, such as 42
.
Grammar Definition
Pkl’s ANTLR 4 grammar is defined in PklLexer.g4 and PklParser.g4.
Reserved keywords
The following keywords are reserved in the language. They cannot be used as a regular identifier, and currently do not have any meaning.
-
protected
-
override
-
record
-
delete
-
case
-
switch
-
vararg
To use these names in an identifier, surround them with backticks.
Blank Identifiers
Blank identifiers can be used in many places to ignore parameters and variables.
_
is not a valid identifier. To use it as a parameter or variable name,
it needs to be enclosed in backticks: `_`.
Functions and methods
birds = List("Robin", "Swallow", "Eagle", "Falcon")
indexes = birds.mapIndexed((i, _) -> i)
function constantly(_, second) = second
For generators
birdColors = Map("Robin", "blue", "Eagle", "white", "Falcon", "red")
birds = new Listing {
for (name, _ in birdColors) {
name
}
}
Let bindings
name = let (_ = trace("defining name")) "Eagle"
Object bodies
birds = new Dynamic {
default { _ ->
species = "Bird"
}
["Falcon"] {}
["Eagle"] {}
}
Projects
A project is a directory of Pkl modules and other resources.
It is defined by the presence of a PklProject
file that amends the standard library module
pkl:Project
.
Defining a project serves the following purposes:
-
It allows defining common evaluator settings for Pkl modules within a logical project.
-
It helps with managing package dependencies for Pkl modules within a logical project.
-
It enables packaging and sharing the contents of the project as a package.
-
It allows importing packages via dependency notation.
Dependencies
A project is useful for managing package dependencies.
Within a PklProject file, dependencies can be defined:
amends "pkl:Project"
dependencies {
["birds"] { (1)
uri = "package://example.com/birds@1.0.0"
}
}
1 | Declare dependency on package://example.com/birds@1.0.0 with simple name "birds". |
These dependencies can then be imported by their simple name. This syntax is called dependency notation.
Example:
import "@birds/Bird.pkl" (1)
pigeon: Bird = new {
name = "Pigeon"
}
1 | Dependency notation; imports path /Bird.pkl within dependency package://example.com/birds@1.0.0 |
Internally, Pkl assigns URI scheme projectpackage to project dependencies imported using dependency notation.
|
When the project gets published as a package, these names and URIs are preserved as the package’s dependencies.
Resolving Dependencies
Dependencies that are declared in a PklProject
file must be resolved via CLI command pkl project resolve
.
This builds a single dependency list, resolving all transitive dependencies, and determines the appropriate version for each package.
It creates or updates a file called PklProject.deps.json
in the project’s root directory with the list of resolved dependencies.
When resolving version conflicts, the CLI will pick the latest semver minor version of each package.
For example, if the project declares a dependency on package A at 1.2.0
, and a package transitively declares a dependency on package A at 1.3.0
, version 1.3.0
is selected.
In short, the algorithm has the following steps:
-
Gather a list of all dependencies, either directly declared or transitive.
-
For each dependency, keep only the newest minor version.
The resolve command is idempotent; given a PklProject file, it always produces the same set of resolved dependencies.
This algorithm is adapted from Go’s minimum version selection. |
Creating a Package
Projects enable the creation of a package.
To create a package, the package
section of a PklProject
module must be defined.
amends "pkl:Project"
package {
name = "mypackage" (1)
baseUri = "package://example.com/\(name)" (2)
version = "1.0.0" (3)
packageZipUrl = "https://example.com/\(name)/\(name)@\(version).zip" (4)
}
1 | The display name of the package. For display purposes only. |
2 | The package URI, without the version part. |
3 | The version of the package. |
4 | The URL to download the package’s ZIP file. |
The package itself is created by the command pkl project package
.
This command only prepares artifacts to be published.
Once the artifacts are prepared, they are expected to be uploaded to an HTTPS server such that the ZIP asset can be downloaded at path packageZipUrl
, and the metadata can be downloaded at https://<package uri>
.
Local dependencies
A project can depend on a local project as a dependency. This can be useful for:
-
Structuring a monorepo that publishes multiple packages.
-
Temporarily testing out library changes when used within another project.
To specify a local dependency, import its PklProject
file.
The imported PklProject
must have a package section defined.
amends "pkl:Project"
dependencies {
["fruit"] = import("../fruit/PklProject") (1)
}
package {
name = "birds"
baseUri = "package://example.com/birds"
version = "1.8.3"
packageZipUrl = "https://example.com/birds@\(version).zip"
}
1 | Specify relative project ../fruit as a dependency. |
amends "pkl:Project"
package {
name = "fruit"
baseUri = "package://example.com/fruit"
version = "1.5.0"
packageZipUrl = "https://example.com/fruit@\(version).zip"
}
From the perspective of project birds
, fruit
is just another package.
It can be imported using dependency notation, i.e. import "@fruit/Pear.pkl"
.
At runtime, it will resolve to relative path ../fruit/Pear.pkl
.
When packaging projects with local dependencies, both the project and its dependent project must be passed to the pkl project package
command.
List
, Set
, and Map
are currently soft keywords. The goal is to eventually turn them into regular standard library methods.
Dynamic
, Pkl’s dynamic object type.
Typed.toDynamic()
returns a dynamic object that amends a typed object.