Values In Depth
Strings that need no quotes, blocks that infer their own kind, references, and the one genuine ambiguity in the language.
You have seen most of these already. This chapter is where the edges get explained — including the one place Psy is genuinely ambiguous, which I would rather tell you about myself than have you discover.
The complete set of value forms:
string
number
boolean
null
list
object
spec reference
property referenceThat is closed. There is no eighth thing, no user-defined value kind, no escape hatch. If it is not on that list, Psy cannot represent it.
Inline, or a block
A value written after : on the same line is inline:
spec Everything:
number: 3
negative: -2.5
boolean: true
nothing: null
quoted: "true"
bare: this is some text
word: example
reference: Other.value
inlineList: [a, b]
inlineObject: { strict: true }The rule for inline values is: if the whole run of text forms a number, a
boolean, null, a quoted string, a dotted reference, an inline list or an inline
object — it is that thing. Otherwise it is an unquoted string.
Compiled to JSON, so you can see which is which:
{
"number": 3,
"negative": -2.5,
"boolean": true,
"nothing": null,
"quoted": "true",
"bare": "this is some text",
"word": "example",
"reference": "…whatever Other.value resolved to",
"inlineList": ["a", "b"],
"inlineObject": { "strict": true }
}Note quoted: "true" came out as the string "true", not the boolean. Quoting is
how you get the text, and it is the only way.
A value written on the following indented lines is a block:
spec A:
asObject:
strict: true
asList:
- one
asString:
some proseHow a block knows what it is
A block is an ordered sequence of operations:
| Operation | Meaning |
|---|---|
key: value | object assignment |
- value | list item |
+ value | composition |
++ value | duplicate-preserving list concatenation |
| raw text | multiline string content |
The kind of the block is inferred from its first non-composition operation. An assignment means object. A dash means list. Raw text means string. If the block is nothing but compositions, the operands decide.
A block may not mix object assignments with list items or raw text — PSY2007.
The one genuine ambiguity
Here it is. Because unquoted strings are ordinary values, a block whose first
line reads word: rest is an object, not a string that happens to start with
a word and a colon.
prompt:
Note: this becomes an object with the key `Note`.{ "prompt": { "Note": "this becomes an object with the key `Note`." } }An object with one key. Almost certainly not what you meant.
Write the prose so it does not lead with word:, or quote it:
prompt:
"Note: this is a string."I have gone back and forth on this one more than anything else in the language.
Every fix costs something worse — a sigil on every multiline string, or a lookahead
rule that is impossible to explain, or making prose quote itself. So it stays,
it is written down as a named rule in the specification (BlockKindInference),
and now you know about it. It has bitten me exactly twice, both times in a
sentence beginning "Note:".
Strings
Strings normally need no quotes:
name: example
mode: safe
description: This is some textLeading and trailing whitespace is trimmed, and a trailing // comment is not
part of the value.
A bare identifier that happens to name a module symbol is a reference, not a string. That rule gets its own section below, because it is the other thing worth being careful about.
Quoted strings
name: "true"
source: "./framework"Inside quotes, \" is a literal quote and \\ is a literal backslash.
Interpolation still works. // needs no escape inside quotes, because a quoted
string never ends at a comment.
Module specifiers must be quoted. That is the only place quoting is mandatory.
Multiline strings
Indentation-only. There are no triple-quoted strings and there is no | or >
to remember.
prompt:
Read the specification carefully.
Then implement it.The rules, which are the ones that make embedded code samples survive:
- The block's own indentation is removed. Deeper indentation is preserved, so a code sample inside a prompt keeps its shape.
- Blank lines inside the block are preserved.
- Blank lines at the very start and end of the block are removed.
- Comments are recognised inside multiline strings and stripped from the value — but the formatter keeps them in your source.
So this:
syntax:
Open a block and write the text on the following indented lines.
prompt:
Read the specification carefully.{
"syntax": "Open a block and write the text on the following indented lines.\n\n prompt:\n Read the specification carefully."
}The last two lines are still indented four spaces relative to the first — the
embedded sample kept its shape. (And yes, that inner prompt: is inside a text run, so it is
prose, not an object — the block already committed to being a string on its first
line. Raw text always runs to the end of its block.)
Raw text runs to the end of its block is worth stating on its own, because it has a consequence: a string block may be preceded by composition operations, but never followed by other operations. Once text starts, text is all there is.
The three escapes
Exactly three, and only inside string content:
| Source | Value |
|---|---|
\\ | a single backslash |
\// | a literal // that is not a comment |
\${ | a literal ${ that is not interpolation |
prompt:
\// This appears in the output.
Write \${Project.name} literally.
A single backslash is written \\.Three escapes is not many. That is deliberate — a config language whose strings need a lookup table is a config language you cannot read.
Interpolation
prompt:
Build ${Project.name}.An interpolation contains a dotted identifier path and nothing else. No expressions, no calls, no formatting mini-language. It resolves exactly like a reference and renders as text.
It works in bare strings, quoted strings and multiline blocks.
Only scalars may be interpolated:
| Value | Renders as |
|---|---|
| string | itself |
| number | its decimal form |
| boolean | true / false |
| null | null |
| spec reference | the spec's name |
| list, object | error PSY4007 |
self is available inside interpolation, which is the whole reason
self exists.
Errors: an unterminated interpolation is PSY1005; contents that are not a
dotted path are PSY2001; a path whose head names nothing is PSY3002.
Lists
tags:
- backend
- testedtags: [backend, tested]Identical meaning. The formatter preserves whichever you chose, because
ports: [80, 443] and a twelve-item block list both want to look the way you
wrote them.
Lists hold scalars, references, objects and other lists:
mixed:
- a scalar
- Other.value
- { key: value }
- [nested, list]{
"mixed": [
"a scalar",
"…whatever Other.value resolved to",
{ "key": "value" },
["nested", "list"]
]
}Structured items
A - alone on its line opens an indented block for that item:
entries:
-
name: first
enabled: true
-
name: second
enabled: false{
"entries": [
{ "name": "first", "enabled": true },
{ "name": "second", "enabled": false }
]
}A dash followed by inline text is that text, as a value. A list item is never
parsed as key: value — so - name: first is the string "name: first", not an
object. If you want a structured entry, use an inline object or an item block.
(This is the same family of decision as block kind inference, resolved the same way: the simple reading wins, and the structured one gets a slightly more explicit syntax.)
Objects
settings:
strict: true
retries: 3settings: { strict: true, retries: 3 }Nested as deep as you like:
settings:
lint:
enabled: true
strict: falseObjects nest freely. Specs never do. Objects are the answer to almost every "but I want structure inside this" question.
A duplicate key — inline or as a repeated key: in a block — is PSY3019.
References
spec A:
retries: 3
spec B:
retries: A.retriesA reference resolves to the referenced value. Referencing a spec's property
resolves that property in the referenced spec's own context, not yours — so
a ${self.name} over there still means over there.
Walking into values
spec Reader:
count: Defaults.retry.count
strict: A.settings.strictProperty access walks object values only. There is no bracket syntax in v1.
Reading a key from a non-object is PSY4006; reading a key that is not there is
PSY3014.
The rule that makes bare strings and references coexist
This is the important one, and it is short:
- A single bare identifier is a reference when it names a symbol in the module namespace, and an unquoted string otherwise.
- A dotted path is always a reference. If its head names nothing, that is an error — not a string.
spec Target:
foo: 1
spec A:
known: Target # a reference to the spec Target
unknown: Something # the string "Something"
broken: Missing.value # error PSY3002Which is what lets name: example and use: [ReadPsySpec] both behave the way
they read, in the same file, with no annotation on either.
The failure mode is real but small: declare a spec called Production, and every
bare Production in that module becomes a reference to it. Quote it if you meant
the word. In practice PascalCase names and lowercase prose keep out of each
other's way, which is a large part of why the naming conventions exist.
Spec references survive
Naming a spec without a property gives you a spec reference, and it stays a spec reference all the way into the compiled output:
use:
- ReadPsySpec
- RunTestsThose are not strings that look like names. They are typed references to specs, which is how a framework can check that every entry really is a skill, and how a renamed spec becomes a compile error instead of a dangling string. See Under The Hood.
Cycles
A property may not depend on itself, directly or through any chain of references,
inheritance or composition. PSY3007.
Constants
Sometimes a value is not a spec and does not want to be.
const ProjectName = Psy
const RetryCount = 3
const Enabled = trueThe = form takes a single inline value. The block form takes anything else — an
object, a list, a multiline string, a composition:
const Defaults:
retries: 3
strict: true
const DefaultTags:
- backend
- testedA constant is an ordinary symbol in the module namespace, so it can be referenced, walked into, and interpolated:
spec Service:
retries: Defaults.retries
name: ProjectName
text:
Building ${ProjectName}.{
"retries": 3,
"name": "Psy",
"text": "Building Psy."
}Constants are PascalCase by convention. They share the namespace with specs and
imports, so duplicates are PSY3001. A constant may not reference itself,
directly or transitively (PSY3007).
What is not composable
Numbers, booleans and null. + and ++ reject them (PSY4002).
There is no meaningful merge of 3 and 5 that is not arithmetic, and Psy does
not do arithmetic. Composing exactly one value is the identity and is always
allowed, so a lone + super on a number is fine — it just does not do anything
interesting.
Layout, in one list
- Spaces only. Tabs are
PSY1001. - Indentation defines blocks.
- A newline terminates a statement. No semicolons.
- Four spaces is the formatter's default.
// commentis the only comment form, and it works everywhere — including inside multiline strings, where it is stripped from the value and kept in your source.- Blank lines and comment-only lines never affect layout. That is what lets a multiline string contain blank lines at all.
Next: More Than One File