Skip to content
Assay

Assay

TOML

import AssayTOML
@Schema(keys: .snakeCase, formats: [.toml])
struct Deployment {
var name: String
var image: String
var replicas: Int
}
let deployment = try Deployment.parse(toml: text)

The parser passes every document of the official toml-test suite — 709 at the time of writing: 208 valid ones decoded to the exact expected value, 501 invalid ones refused. That runs in CI on every commit, with toml++ as a differential oracle beside it.

TOML is the opposite of YAML. The grammar decides the type, so you have nothing to resolve and nothing to coerce:

written is
1 an integer
"1" a string
1.0 a float
true a boolean
1979-05-27 a local date
0xDEAD_BEEF an integer, in hex, with readable underscores

You will never need coerceScalars for TOML. A type mismatch here is a genuine mismatch rather than an ambiguity somebody has to adjudicate for you.

It also means the parser can be strict about literals that look fine but are not:

name = "api"
image = "img:1"
replicas = 01
deploy.toml:3:12: error: invalid number literal
1 │ name = "api"
2 │ image = "img:1"
3 │ replicas = 01
│ ^^
1 error

01 is not a TOML integer. The grammar forbids leading zeros, because they are how octal gets confused with decimal in every language that allows them.

@Schema(keys: .snakeCase, formats: [.toml]) struct Server { var host: String; var port: Int; var tls: Bool = true }
@Schema(keys: .snakeCase, formats: [.toml])
struct Cluster {
var name: String
var servers: [Server]
var labels: [String: String] = [:]
}

[[servers]] is an array of tables. Each header you write appends an element:

name = "eu-prod"
[[servers]]
host = "a.internal"
port = 8080
[[servers]]
host = "b.internal"
port = 8081
tls = false
[labels]
tier = "prod"
team = "platform"
Cluster(name: "eu-prod", servers: [Server(host: "a.internal", port: 8080, tls: true), Server(host: "b.internal", port: 8081, tls: false)], labels: ["team": "platform", "tier": "prod"])

The same document written with inline tables, which is the compact spelling:

name = "eu-prod"
servers = [{host = "a.internal", port = 8080}]
labels = {tier = "prod"}
Cluster(name: "eu-prod", servers: [Server(host: "a.internal", port: 8080, tls: true)], labels: ["tier": "prod"])

Both spellings land in the same struct, because your schema does not care how whoever wrote the file laid it out. Dotted keys are the third spelling and define a table rather than an array of them, so labels.tier = "prod" is the one-line form of the [labels] section above.

This is the rule TOML exists to enforce, and the mistake you are actually likely to make. Writing the same table header twice, usually after a copy-paste, usually in a file long enough that you do not notice.

name = "x"
[labels]
tier = "prod"
[labels]
team = "platform"
cluster.toml:6:2: error: table 'labels' is already defined
4 │ tier = "prod"
5 │
6 │ [labels]
│ ^^^^^^
7 │ team = "platform"
1 error

Most of the difficulty in a TOML parser is here rather than in the tokens. The rule Assay implements is “every value except an open table is closed once parsed”, plus one origin tag per table. The tag records whether it came from a header, from dotted keys, or from being implied by a deeper header — three cases with different rules about what may extend them later, which is why it exists.

An inline table closes the moment its brace shuts, so a = {x = 1} followed by a.y = 2 is an error too. That is the spec, and it is a good rule. An inline table is a value, not a section.

TOML is the only format here with dates in the grammar, and it gives you four of them:

spelling kind
1979-05-27T07:32:00Z offset date-time
1979-05-27T07:32:00 local date-time, no zone
1979-05-27 local date
07:32:00 local time

For a Date field, all of this is already handled:

@Schema(keys: .snakeCase, formats: [.toml])
struct Entry {
var name: String
var createdAt: Date
@DateFormat(.unixSeconds) var seenAt: Date
}
name = "release"
created_at = 1979-05-27T07:32:00Z
seen_at = 1700000000
Entry(name: "release", createdAt: 1979-05-27 07:32:00 +0000, seenAt: 2023-11-14 22:13:20 +0000)

Underneath, a TOML date-time projects to an RFC 3339 string before it reaches your schema — exactly what a Date field already knows how to parse. So you get the same date support every other format gets, @DateFormat candidate chains and the .before / .after / .between rules included.

A date that parses as a shape but is not a real day is refused by the parser:

name = "release"
created_at = 2023-02-29
seen_at = 1
entry.toml:2:14: error: invalid date-time
1 │ name = "release"
2 │ created_at = 2023-02-29
│ ^^^^^^^^^^
3 │ seen_at = 1
1 error

2023 was not a leap year.

There is no way to write one, so encoding has a rule. A nil optional is omitted, and any other null is reported to you rather than silently dropped. See Encoding for what round-trips and what cannot.

TOML.Node keeps the date-time kinds distinct, because collapsing them loses the one piece of information TOML bothered to encode.

odt = 1979-05-27T07:32:00Z
ldt = 1979-05-27T07:32:00
ld = 1979-05-27
lt = 07:32:00
hex = 0xDEAD_BEEF
t["odt"]?.dateTime → offsetDateTime(1979-05-27T07:32:00Z)
t["ldt"]?.dateTime → localDateTime(1979-05-27T07:32:00)
t["ld"]?.dateTime → localDate(1979-05-27)
t["lt"]?.dateTime → localTime(07:32:00)
t["hex"]?.int → 3735928559

TOML.DateTime is an enum with those four cases. By the time you read it the hex literal is an ordinary integer — the radix was a spelling, not a type.

TOML is a tree decoder like YAML and XML, so the argument that makes Assay’s JSON path fast does not apply here. The numbers were parity with C when this parser shipped and are better now: roughly 3.7× toml++ at building the tree, and 6.9× TOMLKit’s Codable decoder end to end. Both moved when the struct door stopped building a TOML.Node table it only projected and threw away.

That second number is the familiar shape — the gap is the Codable boundary, not the parser. Performance has the rest.

  • Property lists — the fifth format, and two encodings of it.
  • Dates — formats, candidate chains, and the rules.
  • Encoding — writing TOML back out.