The Language

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 reference

That 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 prose

How a block knows what it is

A block is an ordered sequence of operations:

OperationMeaning
key: valueobject assignment
- valuelist item
+ valuecomposition
++ valueduplicate-preserving list concatenation
raw textmultiline 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 text

Leading 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:

SourceValue
\\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:

ValueRenders as
stringitself
numberits decimal form
booleantrue / false
nullnull
spec referencethe spec's name
list, objecterror 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
    - tested
tags: [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: 3
settings: { strict: true, retries: 3 }

Nested as deep as you like:

settings:
    lint:
        enabled: true
        strict: false

Objects 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.retries

A 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.strict

Property 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 PSY3002

Which 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
    - RunTests

Those 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 = true

The = 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
    - tested

A 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.
  • // comment is 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

On this page