The Language

Composing Instead Of Copying

What + and ++ do, what they mean for each kind of value, and the one rule that governs block order.

Inheritance lets a child replace what it inherited. That covers maybe half of what you actually want.

The other half is adding to it. You have a base with five tags and you want those five plus one more. You have a base with a settings object and you want it merged with yours rather than obliterated by it. You have a prompt and you want to append a paragraph.

That is composition, and it is spelled +.

spec Base:
    tags:
        - managed
        - internal

spec Api extends Base:
    tags:
        + super
        - public
psy inspect Api.tags
Api.tags (tags.psy)
  = [
    "managed",
    "internal",
    "public"
  ]

  declared in Api at tags.psy
  provenance:
    block list in Api.tags (tags.psy:9:9)
      super Base.tags in Base.tags (tags.psy:2:5)
        literal in Base.tags (tags.psy:3:11)
        literal in Base.tags (tags.psy:4:11)
      literal in Api.tags (tags.psy:11:11)

Or as plain data:

{ "tags": ["managed", "internal", "public"] }

Without the + super line it would be [public], and you would have lost two tags silently. Composition is opt-in, and I think that is right: replacing is the less surprising default.

+ is not arithmetic

Before anything else. There is no arithmetic in Psy. None. + on two numbers is an error (PSY4002), not addition.

I know. It looks like addition. It is not addition. It is "compose these", and what that means depends entirely on what kind of value you handed it — which is the next section, and is the only genuinely fiddly thing in the language.

What + means for each kind

Kind+ does
stringordered block concatenation, joined by a blank line
listconcatenation with stable deduplication
objectrecursive deep merge, leftmost precedence
number, boolean, nullnothing — PSY4002

Mixing kinds in one block is PSY4003. Composing exactly one value is the identity and is always fine.

Now the detail, one kind at a time.

Strings compose as paragraphs

prompt:
    + super
    + Other.prompt

    Additional instructions.

Each contributor becomes a block, and the blocks are joined by a blank line, in source order. Trailing whitespace is trimmed from each one and empty blocks are dropped entirely.

The blank line is the whole reason this is useful. Prompts and descriptions and documentation strings are made of paragraphs, and appending a paragraph to inherited prose is exactly the operation you keep wanting. Joining them with nothing would run two sentences together; joining them with a newline would make one lumpy paragraph. A blank line gives you prose that reads like somebody wrote it.

Lists deduplicate, stably

tags:
    + A.tags
    + B.tags

An item already in the accumulated list is skipped, and the first occurrence keeps its position. With A = [foo, bar] and B = [bar, baz] you get [foo, bar, baz]bar stays where A put it.

Stable matters. If dedup moved items to their last position, adding an unrelated tag to a base spec could reorder a list three levels down, and generated output would churn for no reason.

Objects deep-merge, leftmost wins

settings:
    + A.settings
    + B.settings

A wins every conflict with B. Nested objects are merged rather than replaced:

A.settings = { strict: true,  nested: { depth: 1, fromA: true } }
B.settings = { strict: false, nested: { depth: 9, fromB: true }, depth: 2 }

result     = { strict: true, nested: { depth: 1, fromA: true, fromB: true }, depth: 2 }

Note that nested was merged, not overwritten — fromB survived even though A also had a nested.

Keys keep the position at which they first appear. A later composition that overrides a value does not move the key to the end. This is unglamorous and it is the reason generated JSON and YAML diffs stay readable.

Composing with what you inherited

settings:
    + super

+ super expands to one composition operand per inherited definition, in parent precedence order. For extends A, B, C that is A, then B, then C.

Compare the two forms:

  • foo: super — the first inherited definition. One value.
  • + superall inherited definitions, composed. Everyone contributes.

So for extends A, B, C, a + super on an object means A beats B beats C; on a list it means A's items come first and later duplicates drop; on a string it means A's block is the first paragraph.

++, for when duplicates are the point

tags:
    ++ A.tags
    ++ B.tags

++ is list-only composition that keeps duplicates. Same inputs as before:

A = [foo, bar]
B = [bar, baz]

+   →  [foo, bar, baz]
++  →  [foo, bar, bar, baz]

Lists only. Applying ++ to a string, object, number, boolean or null is PSY4004 — including when it is the only operation in the block, where + would have quietly been the identity. ++ never pretends.

You will not need ++ often. When you do — build steps, ordered pipelines, anything where "run this twice" is meaningful — nothing else will do.

Literal items use -, not +

tags:
    + super
    - production

- introduces a literal item. + composes another value in. They are not interchangeable and the difference bites exactly once:

tags:
    + production     # composes the value of the symbol `production`, if there is one
    - production     # adds the string "production"

Literal - item entries are appended in source order and can be freely mixed with composition, which is what makes + super / - oneMore the idiomatic shape.

The one rule about ordering

Operations inside a block run top to bottom, and later operations win — with exactly one exception: composition operands keep leftmost precedence relative to each other.

That sounds like two rules. It is one rule, stated properly:

A composition replaces a value that an earlier assignment put in place, but never a value that an earlier composition contributed.

Which produces both of the behaviours you would want:

settings:
    strict: false
    + super          # the inherited value wins
settings:
    + super
    strict: false    # the local assignment wins

Read them as: in the first, you are setting a default and then letting your parents override it. In the second, you are taking your parents' settings and then overriding them. Both are useful, both are what the code looks like they do, and neither requires a special case.

And consecutive + operations still resolve as A over B over C, because none of them is an assignment.

Parent precedence and local operation order are separate concepts. Parent precedence is about which spec wins. Local order is about which line wins. Confusing the two is the single most common way to be surprised by a resolved value — and psy inspect will show you exactly which one got you.

Where compositions can appear

Inside an indented block, and nowhere else. Using one as an inline value:

spec Example:
    settings: + Other.settings      // error PSY2015
settings.psy:2:15 error PSY2015: composition operators may only appear inside an
indented block.

Open a block.

A worked example

spec A:
    tags: [foo, bar]
    settings:
        strict: true
        retries: 3
    prompt:
        First block.

spec B:
    tags: [bar, baz]
    settings:
        strict: false
        depth: 2
    prompt:
        Second block.

spec Merged:
    tags:
        + A.tags
        + B.tags
    settings:
        + A.settings
        + B.settings
    prompt:
        + A.prompt
        + B.prompt

        Extra.
Merged.tags     = [foo, bar, baz]
Merged.settings = { strict: true, retries: 3, depth: 2 }
Merged.prompt   = "First block.\n\nSecond block.\n\nExtra."

Compiled to JSON:

{
  "tags": ["foo", "bar", "baz"],
  "settings": {
    "strict": true,
    "retries": 3,
    "depth": 2
  },
  "prompt": "First block.\n\nSecond block.\n\nExtra."
}

Or to YAML, where the composed string becomes a literal block scalar:

tags:
  - foo
  - bar
  - baz
settings:
  strict: true
  retries: 3
  depth: 2
prompt: |-
  First block.

  Second block.

  Extra.

Three kinds, three different meanings of +, one syntax. If you can read that example you can read any composition in any Psy file.

Next: Values In Depth

On this page