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
- publicpsy inspect Api.tagsApi.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 |
|---|---|
| string | ordered block concatenation, joined by a blank line |
| list | concatenation with stable deduplication |
| object | recursive deep merge, leftmost precedence |
| number, boolean, null | nothing — 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.tagsAn 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.settingsA 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.+ super— all 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 winssettings:
+ super
strict: false # the local assignment winsRead 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 PSY2015settings.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