The Language

Building On What You Already Wrote

What a spec actually is, how extends resolves, and the difference between super and self.

Psy has exactly one primitive. A spec.

(I did think about adding more. I kept not needing to, and every time I nearly did, the thing I actually wanted turned out to be inheritance wearing a hat.)

spec Defaults:
    retries: 3
    strict: true

spec Production extends Defaults:
    retries: 5
psy inspect Production
Production (defaults.psy)
  abstract: false
  precedence: defaults.psy#Production > defaults.psy#Defaults

  retries = 5
  declared in Production at defaults.psy
  strict = true
  declared in Defaults at defaults.psy

That is the whole idea. The rest of this chapter is the parts that are not obvious.

A spec is not a class

This trips people up for about ten minutes, so let me get it out of the way.

A spec is never instantiated. There is no new, no constructor, no instance. A spec is the value structure. Resolving it produces data, and that data is what the compiler hands to whatever consumes it.

So when you read spec Production extends Defaults, do not think "subclass". Think "this document, plus these changes".

The shape of one

[export] [abstract] spec Name [extends A, B, C] [as keyword]:
    <members>

Names are PascalCase by convention, properties are camelCase, and the linter will tell you when you drift. Neither is enforced by the parser — you can turn both rules off — but the generated output of half the tooling assumes them, so you may as well.

Specs never nest

spec Outer:
    foo: 1

    spec Inner:          // error PSY2011
        bar: 2

Specs are always top-level. Always. If you want nested structure, that is what objects are for, and they nest as deep as you like.

I made this rule early and have never regretted it. A nested spec would need a name that is only meaningful inside its parent, which means it needs a scoping rule, which means references need a scoping rule, which means... no.

Order does not matter

spec A extends B:
    foo: 1

spec B:
    bar: 2

That works. The compiler binds every top-level declaration before it resolves any of them, so a spec may extend one that is declared eighty lines further down. Put your declarations in whatever order reads best.

Say a property once

spec Thing:
    retries: 3
    retries: 5      // error PSY3019

Declaring the same property twice in one spec is an error. If you are trying to build a value out of several pieces, you want a block with composition operations, which is the next chapter.

How a property is actually resolved

Given a spec S and a property p:

  1. If S declares p, evaluate that declaration in S's context.
  2. Otherwise find the first spec in S's precedence order that declares p, and evaluate that declaration in S's context.
  3. If nothing declares it, S simply has no p.

Step 2 is doing more work than it looks like. Note what it does not say: it does not say "take the parent's resolved value". It says take the parent's definition and re-run it here.

That distinction is invisible until you meet self, at the bottom of this page, and then it is the whole ballgame.

Extending several things

spec Child extends A, B, C:
    plain: 1

Precedence is left to right, strongest first:

A > B > C

So:

spec A:
    foo: fromA
    only: onlyA

spec B:
    foo: fromB
    bar: fromB

spec C:
    foo: fromC
    baz: fromC

spec Child extends A, B, C:
    plain: 1
Child.foo   = "fromA"
Child.bar   = "fromB"
Child.baz   = "fromC"
Child.only  = "onlyA"
Child.plain = 1

Leftmost wins, and the ones to its right fill in what it did not mention.

The diamond

When two parents share an ancestor, the ancestor should appear once, and it should appear after everything that inherits it. That is what a linearization is for.

spec Root:
    root: 1
spec Left extends Root:
    left: 1
spec Right extends Root:
    right: 1
spec Child extends Left, Right:
    child: 1
Child > Left > Right > Root

Psy computes that with a C3-style merge, which is the same algorithm Python uses and for the same reasons. When a hierarchy admits no consistent C3 order at all, Psy falls back to plain left-to-right depth-first rather than failing.

That fallback is a deliberate choice and I will defend it: a config language should not refuse to compile because your inheritance graph is unusual. The result is always total, always deterministic, and never depends on iteration order. It might just not be the order you would have picked.

psy inspect <Spec> prints the whole linearization as the precedence: line, so you never have to work it out on paper.

What you can and cannot extend

Only specs. Extending a constant, or an imported thing that is not a spec, is PSY3011. Extending a name that does not exist at all is PSY3002. And a spec may not appear in its own inheritance chain — PSY3006, no exceptions, no "but it would be useful if".

super — reaching what you inherited

spec Child extends A, B, C:
    foo: super

super resolves to the first matching inherited value, in precedence order: A.foo, else B.foo, else C.foo.

super always means "this same property, in my parents". foo: super looks for foo. It cannot reach a differently-named property, and there is no super.somethingElse syntax. One job.

There is a second form — + super, which composes all the inherited values rather than taking the first — and it is the one you will use more often. It belongs with the other composition operators, so it is next chapter.

super binds lexically

Here is the rule that will save you a debugging session eventually.

super is relative to the spec whose source is being evaluated, not to the spec the property is being resolved for.

spec Grand:
    prompt:
        grand

spec Parent extends Grand:
    prompt:
        + super

        parent

spec Child extends Parent:
    other: 1
Child.prompt = "grand\n\nparent"

Rendered, that is:

grand

parent

When Parent's definition gets re-evaluated on behalf of Child, its super still means Grand — because that is what super meant where it was written. It does not suddenly mean Parent just because Child is asking.

Lexical. Like a closure. That is the mental model.

When super has nothing to reach

  • super in a spec with no parents at all is PSY3012.
  • super where no parent declares that property is PSY3013.
  • A super chain that loops back on itself is PSY3007.

self — asking who is being resolved

And now the opposite.

self.name
self.keyword
self.abstract
FieldValue
self.namethe spec's name, as a string
self.keywordthe framework keyword it was declared with, or null
self.abstracttrue for abstract specs

Any other field is PSY3017, as is reaching for self outside a spec.

self is late bound. It always names the spec a property is being resolved for, never the spec that wrote the definition:

spec Parent:
    label:
        I am ${self.name}.

spec Child extends Parent:
    other: 1
Parent.label = "I am Parent."
Child.label  = "I am Child."

As data:

{
  "Parent": { "label": "I am Parent." },
  "Child":  { "label": "I am Child.", "other": 1 }
}

One definition. Two answers. That only works because resolution re-evaluates definitions rather than caching parent values — which is the thing I flagged as doing more work than it looked like, about a page ago.

Why you would want this

export abstract spec Described as described:
    abstract summary: string

    label:
        Definition ${self.name} declared as ${self.keyword}.

export described Overview:
    summary:
        Everything, briefly.
psy inspect Overview.label
Overview.label (described.psy)
  = "Definition Overview declared as described."

  declared in Described at described.psy
  provenance:
    block string in Described.label (described.psy:5:9)
      interpolation self.name in Described.label (described.psy:6:20)
      interpolation self.keyword in Described.label (described.psy:6:44)

Written once, on the base, correct for every descendant forever — and the provenance names the two interpolations that produced it.

This is how a framework attaches boilerplate that has to mention the thing it is attached to. Without it, every declaration repeats its own name back at itself and one of them is eventually wrong.

The two of them together

BindsMeans
superlexicallythe parents of whoever wrote this line
selfdynamicallywhoever is being resolved right now

A parent can write one definition that reads correctly for every descendant (self) while still reaching its own ancestors (super). Those pull in opposite directions on purpose.

Next: Composing Instead Of Copying

On this page