Settings: config.jadpo
On this page
A setting is a value that can differ between deployments, such as the
database's address or the address emails are sent from. Every setting your
app needs is a field of one config { } declaration, in the file
config.jadpo at the project's root. A field has a type and, optionally,
secret: true, a default:, a binding: and a test: value, each
explained below. Code reads a setting as config.<field> anywhere,
functions included, because a setting can't change while the app runs.
config { browser_origin: Url database_url: PostgresUrl mail_sender: Email session_signing_key: SigningKey soft_delete_retention: Duration { default: 30d }}
Each value comes from an environment variable named after the field in
capitals: mail_sender reads MAIL_SENDER. The declaration has no name,
since a project has only one. There is no env.jadpo and no second file
that maps settings to variables.
A field is required unless it has a default:. There are no optional
settings: give the field a default, or leave the feature's setting
required.
What code reads has the field's type. config.page_size below is the
ListLimit the field declares, checked when the app starts, so code never
builds it again. Declare the field with the named type your code uses
(page_size: ListLimit { default: 25 }) and read config.page_size
wherever a ListLimit goes. You can declare a setting with a built-in type
such as Duration or Int, but a function's parameters and results take
named types (Signatures use named types).
Building a named type from a plainly typed setting is an ordinary
construction, which can fail, so it needs attempt.
type ListLimit = Int { min: 1 max: 100}type FromAddress = Email {}config { mail_sender: FromAddress { test: "reminders@example.test" } page_size: ListLimit { default: 25 }}function first_page() -> ListLimit { return config.page_size}function from_address() -> FromAddress { return config.mail_sender}
Environment variable names
By default the variable's name is the field's name in capitals. Write
binding: only when it must be something else, for example to keep a name
a hosting platform already sets:
config { database_url: PostgresUrl { binding: "HEROKU_POSTGRESQL_URL" }}
A binding is capital letters, digits and _, starting with a capital
letter, and no two settings read the same variable. A SigningKey setting
also reads its variable with _PREVIOUS after it
(Signing keys), so no other setting may bind that name.
Because the name comes from the field, renaming a field renames its variable, and a deployment that still sets the old name breaks. So a changed variable name is shown for review like any other change to the app's contract (Records and manifest).
What a setting can hold
A setting is read from one environment variable, so its type must be one that can be read from a single piece of text:
- text-based types,
Int,Decimal,Bool,Uuid,Email,Url,IpAddress,Duration,InstantandCalendarDate, and any named type built on one of them; - an enum whose variants carry no data, and the built-in enums
ZoneandLocale; - a
ModelId, read from its text such asopenai/gpt-4.1-mini, since every model an app'sai:names is one (AI); - a
Money, read from its canonical text such as"GBP 20.00", since an AI budget,ai: { limits: { per_day: { cost: } } }, names one; - a
Certificate, read from its PEM text, since a database connection'sauthority:names one (PostgreSQL over TLS).
Lists, objects, maps, T? and enums whose variants carry data are refused
(configuration.type_unsupported). Split such a value into several fields.
type Verbosity = Enum { verbose normal terse}config { business_zone: Zone { default: Zone.europe_london } log_level: Verbosity { default: Verbosity.normal }}
Here LOG_LEVEL=verbose and BUSINESS_ZONE=Europe/London are valid values.
Each type is written this way in a variable:
- an enum as the variant's name;
- a
Zoneas its IANA name, such asEurope/London; - a
Localeas its BCP 47 tag, such asen-GB; - a
Durationas it is in source (30s,5m,2h,30d) or as its ISO 8601 text (PT5M); - a
Boolastrueorfalse; - an
Instantin ISO 8601 ending inZ, never with an offset, and aCalendarDatein ISO 8601.
A variable set to nothing counts as not set.
Defaults and test values
A default is a literal of the field's type, such as default: 25,
default: 5s or default: Verbosity.normal. The compiler checks it against
every rule of the type and of the types it is built on. test: is the value
every test reads, and is checked the same way. A field with no default that
a test reads needs a test: value
(Configuration in tests).
Neither can name a constant, even though a constant is also fixed when the code compiles: a value is a constant or a setting, never both (A constant or a setting).
A secret can have neither. So a production secret someone forgot to set stops the app, instead of falling back to a value written in source, and secrets never appear in test code.
A constant or a setting
A value that could differ between deployments is a setting. One that never
does is a constant. A value is one or the other,
never both, so it has one source and one way to read it: a constant is read
as security.max_login_attempts, a setting as config.max_login_attempts.
That is why a default: never names a constant. If it could, code reading
the constant directly would quietly ignore the environment. Say
security.standard_attempts (5) were max_login_attempts's default, and
someone set MAX_LOGIN_ATTEMPTS=10: code reading
security.standard_attempts would still allow 5. default: on the field
already names the default, in the one place a reader looks.
There are three kinds of value, from fixed to changing while the app runs:
| Kind | Where | Changes | Read as |
|---|---|---|---|
constant |
Beside its use, or in a library (Constants) | With a code change | security.max_login_attempts |
| Setting | config { } in config.jadpo |
Per deployment, when the app starts | config.max_login_attempts |
| Flag | flags { } in flags.jadpo (Feature flags) |
While the app runs | flags.new_checkout |
Making a constant a setting
When a fixed value needs to differ per deployment, a tool makes the change
for you: jadpo config move <constant> on the command line, or "Make this
a setting" in the editor on the constant's declaration or any read of it.
Both run the same code, as the editor's rename runs jadpo rename
(Editor support). The move is offered,
never required, so jadpo fmt --layout, which makes only the moves an
error asks for, doesn't make it. In one step it:
- adds a field to the one
config { }, before the first field whose name comes after it (in a newconfig.jadpowhen there is none), with the constant's name and type, whosedefault:is the constant's value as written when that is a literal (5,30d,0.2000,Verbosity.normal) and otherwise the value the compiler worked out, and whose///lines are the constant's; - deletes the constant, and a library whose only member it was, and a file left holding nothing (with its folder, if that is left empty);
- rewrites every read,
security.max_login_attempts(ormax_login_attemptsbeside its declaration) toconfig.max_login_attempts, and fixes the imports of every file it touches, removing auseline nothing needs any more.
<constant> names the constant as code reads it, security.max_login_attempts or
free_delivery_from, or after its domain
(accounts.security.max_login_attempts) when private constants of two
domains share the name; a name that fits two is refused, listing each.
Before, a library in the accounts domain holds the constant and an
action reads it:
use accounts/types { LoginAttempts }library security { /// Failed sign-ins before an account is locked. constant max_login_attempts: LoginAttempts = 5}
use accounts/types { AccountLocked, LoginAttempts }use accounts/libraries { security }action sign_in(failed_attempts: LoginAttempts) -> AccountLocked { return AccountLocked(failed_attempts >= security.max_login_attempts)}
The move lists what it changed, file by file (the setting added or filled, the constant and anything removed with it, and each read by line; imports are left to the diff), then the setting and the variable a deployment can now set:
$ jadpo config move security.max_login_attempts config.jadpo + max_login_attempts setting, default 5 domains/accounts/libraries/security.jadpo - max_login_attempts constant - security library, nothing left in it - security.jadpo file, nothing left in it domains/accounts/actions/sign_in.jadpo ~ line 5 reads config.max_login_attemptsMade security.max_login_attempts a setting setting config.max_login_attempts variable MAX_LOGIN_ATTEMPTS
After, the library held nothing else, so it and its file are gone, and the imports follow:
use accounts/types { LoginAttempts }config { /// Failed sign-ins before an account is locked. max_login_attempts: LoginAttempts { default: 5 }}
use accounts/types { AccountLocked, LoginAttempts }action sign_in(failed_attempts: LoginAttempts) -> AccountLocked { return AccountLocked(failed_attempts >= config.max_login_attempts)}
Every read changes, so a reviewer sees each place whose behaviour can now
differ per deployment, and the tool does the rewriting. The new variable,
MAX_LOGIN_ATTEMPTS, comes from the field's name as any setting's does.
The move refuses, editing nothing and saying why, for:
- a constant whose type a setting can't hold (What a setting can
hold: a list, an object,
T?, an enum with data); - a constant whose name a setting already has, naming that setting;
- a constant read where a setting can't be: in a query, an update by
id, a backfill or another constant's value, listing each such read.
Each reason is an error of its own: configuration.move_type_unsupported,
configuration.move_name_taken and configuration.move_read_unsupported,
which are command errors. They are all
given at once, ending config move refused: nothing changed.
A project with other errors isn't moved: its errors are shown as jadpo check shows them, and the editor shows the action as unavailable. A result
that wouldn't check (another constant left unused, say) is undone and
refused with the errors it would have, and nothing is written.
When exactly one setting's default: names the constant
(configuration.default_literal_required, below), that error alone
doesn't stop the move: that setting is the one the constant becomes, its
default: becomes the constant's value, and every read becomes
config.<that setting>.
Moving a setting back to a constant has no command. It is rare, and removing a setting changes every deployment's environment, so you make it by hand.
Jadpo has no constant that an environment variable can override, no
config { } block per domain, no settings gathered from beside their use,
and no values per environment in source (production:, staging:). Each
would make a value look fixed where it differs per deployment, or lose the
one list of variables an operator sets (Values on your machine and in
production).
Fields in name order
jadpo fmt sorts the fields of config { } by name, each with the comments
written above it and after it on its line. So two changes that each add a
setting rarely touch the same lines, and they merge cleanly.
A setting's position means nothing: it is read, bound and reported by name.
jadpo config check and the check before the app starts list settings in
the order they are written, which is name order once formatted. jadpo fmt --check fails on a config { } out of order. The examples on these pages
are in name order.
Markdown for agents: /docs/settings-config-jadpo.md.Built from Jadpo main at a707efb.