Skip to content
Assay

Assay

An application config file

A config error at start-up should read to you like a compiler error, because that is exactly what it is: a static mistake in a file, found before anything of yours runs.

@Schema(keys: .snakeCase, unknownKeys: .warn, formats: .all)
struct AppConfig {
@Validate(.min(1)) var serviceName: String
var port: Int = 8080
var logLevel: String = "info"
@Validate(.range(1...64)) var workers: Int = 4
@Validate(.url) var upstream: String?
}
func loadConfig(_ text: String, named name: String) -> (AppConfig?, String) {
let d = AppConfig.diagnose(toml: text, sourceName: name)
// `.plain` renders warnings alongside errors already. Looping over `d.warnings` and
// appending them is the obvious next line and it prints every warning twice.
return (d.isValid ? d.value : nil, d.render(.plain))
}

Three decisions in that declaration, and they are the whole recipe.

It is also quick enough that you can do it on every boot without thinking about it: that struct comes out of TOML at 6.92× TOMLKit’s Codable decoder, and the tree underneath it at 3.69× toml++, which is C.

Defaults live on the property. port is 8080 when the file does not mention it. You keep no separate defaults table in step, and you unwrap no optional at every use site. See presence for the five states and when each applies.

unknownKeys: .warn rather than the default .ignore. For an API response, ignoring unknown keys is right — the server adds fields without asking you. For your own config file it is the opposite: an unknown key is almost always a typo, and silence means somebody’s setting quietly does nothing for a week.

formats: .all, so the same struct reads YAML and JSON too. Your users have opinions about config formats. Accommodating all of them costs you one attribute.

service_name = "checkout"
port = 9000
upstream = "https://inventory.internal"
AppConfig(serviceName: "checkout", port: 9000, logLevel: "info", workers: 4, upstream: Optional("https://inventory.internal"))

port came from the file, log_level and workers from the declaration.

service_name = ""
prot = 9000
workers = 900
upstream = "inventory"
app.toml:1:16: error: service_name must be at least 1 character
1 │ service_name = ""
│ ^^
2 │ prot = 9000
app.toml:3:11: error: workers must be between 1 and 64
1 │ service_name = ""
2 │ prot = 9000
3 │ workers = 900
│ ^^^
4 │ upstream = "inventory"
app.toml:4:12: error: upstream must be a valid URL
2 │ prot = 9000
3 │ workers = 900
4 │ upstream = "inventory"
│ ^^^^^^^^^^^
app.toml:2:8: warning: unknown key "prot"; did you mean "port"?
1 │ service_name = ""
2 │ prot = 9000
│ ^^^^
3 │ workers = 900
3 errors, 1 warning

Every problem at once, each one pointing at the line and the bytes, so you fix the file in one pass rather than one error per run.

The last one is the reason for .warn. prot is not a key this schema knows, and rather than being dropped it is named, with a suggestion worked out by edit distance. That is did-you-mean, and for whoever wrote the file it is the difference between a five-second fix and an afternoon.

.plain is the render above: no colour, suitable for a log or a file. .terminal is the same thing with colour, and it turns colour off by itself when output is not a terminal, so you do not need to check.

let (config, report) = loadConfig(try String(contentsOf: url, encoding: .utf8),
named: url.lastPathComponent)
guard let config else {
FileHandle.standardError.write(Data(report.utf8))
exit(78) // EX_CONFIG
}

Print the report for a valid file as well. It is empty when there is nothing to say, and it carries your warnings when there is.

Assay does not read the environment, and should not: the merge order between file, flags and environment is your policy, not a decoder’s. Decode the file, then overlay:

var config = try AppConfig.parse(toml: text)
if let p = ProcessInfo.processInfo.environment["PORT"], let n = Int(p) { config.port = n }

If you want the overlay validated too, try AppConfig.validate(config) runs the same rules against the finished value without decoding anything. It is a different function from the parse verbs, and the law it holds is that a value that came out of parse never fails it.

  • TOML and YAML — the formats, in full.
  • Presence — defaults, optionals, and the two spellings that do not compile.
  • Keys — naming conventions, aliases, and unknown-key policy.