Introducing Pkl, a programming language for configuration

We are delighted to announce the open source first release of Pkl (pronounced Pickle), a programming language for producing configuration.

When thinking about configuration, it is common to think of static languages like JSON, YAML, or Property Lists. While these languages have their own merits, they tend to fall short when configuration grows in complexity. For example, their lack of expressivity means that code often gets repeated. Additionally, it can be easy to make configuration errors, because these formats do not provide any validation of their own.

To address these shortcomings, sometimes formats get enhanced by ancillary tools that add special logic. For example, perhaps there’s a need to make code more DRY, so a special property is introduced that understands how to resolve references, and merge objects together. Alternatively, there’s a need to guard against validation errors, so some new way is created to validate a configuration value against an expected type. Before long, these formats almost become programming languages, but ones that are hard to understand and hard to write.

On the other end of the spectrum, a general-purpose language might be used instead. Languages like Kotlin, Ruby, or JavaScript become the basis for DSLs that generate configuration data. While these languages are tremendously powerful, they can be awkward to use for describing configuration, because they are not oriented around defining and validating data. Additionally, these DSLs tend to be tied to their own ecosystems. It is a hard sell to use a Kotlin DSL as the configuration layer for an application written in Go.

We created Pkl because we think that configuration is best expressed as a blend between a static language and a general-purpose programming language. We want to take the best of both worlds; to provide a language that is declarative and simple to read and write, but enhanced with capabilities borrowed from general-purpose languages. When writing Pkl, you are able to use the language features you’d expect, like classes, functions, conditionals, and loops. You can build abstraction layers, and share code by creating packages and publishing them. Most importantly, you can use Pkl to meet many different types of configuration needs. It can be used to produce static configuration files in any format, or be embedded as a library into another application runtime.

We designed Pkl with three overarching goals:

  • To provide safety by catching validation errors before deployment.

  • To scale from simple to complex use-cases.

  • To be a joy to write, with our best-in-class IDE integrations.

A Quick Tour of Pkl

We created Pkl to have a familiar syntax to developers, and to be easy to learn. That is why we’ve included features like classes, functions, loops, and type annotations.

For example, here is a Pkl file (module) that defines a configuration schema for an imaginary web application.

This file defines types, and not data. This is a common pattern in Pkl, and we call this a template.
Application.pkl
module Application

/// The hostname that this server responds to.
hostname: String

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

/// The environment to deploy to.
environment: Environment

/// The database connection for this application
database: Database

class Database {
  /// The username for this database.
  username: String

  /// The password for this database.
  password: String

  /// The remote host for this database.
  host: String

  /// The remote port for this database.
  port: UInt16

  /// The name of the database.
  dbName: String
}

typealias Environment = "dev"|"qa"|"prod"
pkl

And here is how configuration data might be defined:

localhost.pkl
amends "Application.pkl"

hostname = "localhost"

port = 3599

environment = "dev"

database {
  host = "localhost"
  port = 5786
  username = "admin"
  password = read("env:DATABASE_PASSWORD") (1)
  dbName = "myapp"
}
pkl
1 Built-in read expression for reading external resources.

It is easy to create variations of the same base data by amending. For example, let’s imagine that we want to run four databases locally, as sidecars. This uses a for generator to produce four variations, each of which amends the base db and specifies a different port.

sidecars.pkl
import "Application.pkl"

hidden db: Application.Database = new {
  host = "localhost"
  username = "admin"
  password = read("env:DATABASE_PASSWORD")
  dbName = "myapp"
}

sidecars {
  for (offset in List(0, 1, 2, 3)) {
    (db) {
      port = 6000 + offset
    }
  }
}
pkl

Pkl programs can be easily rendered to common formats.

$ export DATABASE_PASSWORD=hunter2
$ pkl eval --format yaml sidecars.pkl
text
sidecars:
- username: admin
  password: hunter2
  host: localhost
  port: 6000
  dbName: myapp
- username: admin
  password: hunter2
  host: localhost
  port: 6001
  dbName: myapp
- username: admin
  password: hunter2
  host: localhost
  port: 6002
  dbName: myapp
- username: admin
  password: hunter2
  host: localhost
  port: 6003
  dbName: myapp
yaml

Built-in Validation

Configuration is about data. And data needs to be valid.

In Pkl, validation is achieved using type annotations. And, type annotations can optionally have constraints defined on them.

Here is an example, that defines the following constraints:

  • age must be between 0 and 130.

  • name to not be empty.

  • zipCode must be a string with five digits.

Person.pkl
module Person

name: String(!isEmpty)

age: Int(isBetween(0, 130))

zipCode: String(matches(Regex("\\d{5}")))
pkl

A failing constraint causes an evaluation error.

alessandra.pkl
amends "Person.pkl"

name = "Alessandra"

age = -5

zipCode = "90210"
pkl

Evaluating this module fails:

$ pkl eval alessandra.pkl
–– Pkl Error ––
Type constraint `isBetween(0, 130)` violated.
Value: -5

5 | age: Int(isBetween(0, 130))
             ^^^^^^^^^^^^^^^^^
at Person#age (file:///Person.pkl)

5 | age = -5
          ^^
at alessandra#age (file:///alessandra.pkl)

106 | text = renderer.renderDocument(value)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
at pkl.base#Module.output.text (https://github.com/apple/pkl/blob/0.25.0/stdlib/base.pkl#L106)
text

Constraints are arbitrary expressions. This allows you to author types that can express any type of check that can be expressed in Pkl. Here is a sample type that must be a string with an odd length, and whose first letter matches the last letter.

name: String(length.isOdd, chars.first == chars.last)
pkl

Sharing Packages

Pkl provides the ability to publish packages, and to import them as dependencies in a project. This provides an easy way to share Pkl code that can be used in other projects.

It is easy to create your own package and publish them as GitHub releases, or to upload them anywhere you wish.

Packages can be imported via the absolute URI:

import "package://pkg.pkl-lang.org/pkl-pantry/pkl.toml@1.0.0#/toml.pkl"

output {
  renderer = new toml.Renderer {}
}
{pkl

Alternatively, they can be managed as dependencies of a project. Using a project allows Pkl to resolve version conflicts between different versions of the same dependency within a dependency graph. It also means that you can import packages by a simpler name.

PklProject
amends "pkl:Project"

dependencies {
  ["toml"] { uri = "package://pkg.pkl-lang.org/pkl-pantry/pkl.toml@1.0.0" }
}
pkl
myconfig.pkl
import "@toml/toml.pkl"

output {
  renderer = new toml.Renderer {}
}
pkl

A set of packages are maintained by us, the Pkl team. These include:

  • pkl-pantry — a monorepo that publishes many different packages.

  • pkl-k8s — templates for defining Kubernetes descriptors.

Language Bindings

Pkl can produce configuration as textual output, and it can also be embedded as a library into other languages via our language bindings.

When binding to a language, Pkl schema can be generated as classes/structs in the target language. For example, the Application.pkl example from above can be generated into Swift, Go, Java, and Kotlin. Pkl even includes documentation comments in the target language.

import PklSwift

public enum Application {}

extension Application {
    public enum Environment: String, CaseIterable, Decodable, Hashable {
        case dev = "dev"
        case qa = "qa"
        case prod = "prod"
    }

    public struct Module: PklRegisteredType, Decodable, Hashable {
        public static var registeredIdentifier: String = "Application"

        /// The hostname that this server responds to.
        public var hostname: String

        /// The port to listen on.
        public var port: UInt16

        /// The environment to deploy to.
        public var environment: Environment

        /// The database connection for this application
        public var database: Database

        public init(hostname: String, port: UInt16, environment: Environment, database: Database) {
            self.hostname = hostname
            self.port = port
            self.environment = environment
            self.database = database
        }
    }

    public struct Database: PklRegisteredType, Decodable, Hashable {
        public static var registeredIdentifier: String = "Application#Database"

        /// The username for this database.
        public var username: String

        /// The password for this database.
        public var password: String

        /// The remote host for this database.
        public var host: String

        /// The remote port for this database.
        public var port: UInt16

        /// The name of the database.
        public var dbName: String

        public init(username: String, password: String, host: String, port: UInt16, dbName: String) {
            self.username = username
            self.password = password
            self.host = host
            self.port = port
            self.dbName = dbName
        }
    }
}
swift

Using code generation is just one of the many ways to embed Pkl within an application. Our language bindings also provide evaluator APIs to control Pkl evaluation at a low level, and users are free to interact with Pkl at the abstraction level that makes the most sense for their application.

Editor Support

We believe that a programming language is only as good as the experience of writing it. That is why we aim to provide best-in-class editor support. When writing Pkl in an editor, users are guided through the process of filling in configuration data from a given template. Additionally, the editors provide instant feedback if any values are invalid, and documentation is immediately available when called upon.

We are also releasing our IntelliJ plugin, which provides rich support for JetBrains editors, including IntelliJ, Webstorm, GoLand, and PyCharm. These plugins are able to analyze a Pkl program and provide features like autocompletion, go-to-definition, and refactoring support.

Here are some of the features that are available:

Autocomplete in IntelliJ

In addition, we are also planning on supporting the Language Server Protocol, which will provide a similar level of integration in other editors.

As of 2024/10/10, The Pkl Language Server has been released. This enables rich editor support for our VS Code and Neovim plugins.

Next Steps

We hope you like what we’ve shown you so far. For a more in-depth guide, take a look at our tutorial. To learn more about the language itself, read through our language reference. To connect with us, feel free to submit a topic on GitHub Discussions.

Additionally, feel free to browse our sample repositories to get an idea for what it’s like to use Pkl:

To try out Pkl locally, try downloading our CLI by following our installation guide. Additionally, try installing one of our various editor plugins to get a glimpse of what it’s like to write Pkl yourself.

We’re so excited to share Pkl with you, and we are just getting started. We are looking forward to seeing what you might do with it!