Lesson 05 — Free arguments and how they propagate
Goal: by the end of this lesson you understand what makes a slot "free", how that freedom flows up through composition into the caller's interface, and how to write reusable templates by leaving slots intentionally unbound.
Concepts introduced: free argument, bound vs free slot,
free-arg propagation, template fn-def, :as rename, free-arg
placeholder edges on the canvas + the deep-free ⇣
bottom-of-card strip.
What "free" means
A slot is FREE on a fn iff no fn in its :parent-ids BFS
closure binds it. The slot is declared somewhere (a base-fn,
type-row, or rename), but no ancestor has supplied a :value
or :ref-fn-id for it. The runtime can't execute the fn
without that slot being filled — so the CALLER (whoever
invokes the fn) has to supply it.
;; :str-len is a base-fn declaring :string as a slot.
{:name :str-len
:args {:string {:type :text}}
:return-type :int}
:str-len has ONE slot. No fn binds it (it's a base-fn — there's
no :parent-ids to walk). So :string is free. To execute
:str-len, the caller passes {:string "hello"}.
Now compose:
{:name :greet-len
:parent :str-len
:args {:string :greeting}} ;; binds :string to a ref
{:name :greeting
:parent :const
:args {:value "Hello, world!"}}
:greet-len BINDS :string to a ref :greeting. Now the
caller of :greet-len doesn't need to supply :string —
graphden invokes :greeting at runtime and feeds the result
to :str-len. So :greet-len has ZERO free args. ▶ Run
opens the Run pane saying "No free arguments — click Run
to invoke" — no form to fill, one confirming click.
If you think in Clojure
A graph fn has no separate parameter list — the argument
vector is derived from the body. The closest Clojure analogue is
the #(...) literal, where %1/%2 in the body both use and
declare the parameters:
| Clojure | Graphden |
|---|---|
(defn f [label] …) — params declared in [] |
no declaration site: every unbound slot / {:as :label} capture in the body IS a parameter |
body uses label |
the capture site both uses and declares it |
(f "Run") — positional call |
binding by name: :args {:label "Run"}, the Run form, /api/execute args |
| threading a param down by hand at every level | automatic: an unclosed hole at ANY depth surfaces as the top fn's parameter |
(partial f 5) / a factory returning a closure |
a child fn-def binding some args — the rest stay free (Lesson 09) |
The derived argument vector is always visible: it is exactly the set of placeholder edges on the card, and the Run form's fields.
Free args bubble up
This is the load-bearing property. Watch:
{:name :longer-than
:parent :gt
:args {:nums [:str-len-of ; nums[0] = the length (a ref)
{:as :threshold}]}} ; nums[1] = threshold (free)
{:name :str-len-of
:parent :str-len
:args {}} ;; :string is left UNBOUND
:gt compares its :nums list pairwise (nums[0] > nums[1] > …),
so this reads "length is greater than threshold". What are
:longer-than's free args?
:str-len-ofhas:stringfree (it bound nothing to:str-len.:string).:longer-thanrefs:str-len-ofas the first list item. So:longer-thanINHERITS the free-ness::stringpropagates up as a free arg of the caller.:longer-thanALSO has:threshold— the second list item, surfaced as a free arg via:as.
So :longer-than's public interface is {:string ... :threshold ...}. Two free args. The editor draws each of them as a
placeholder node on the canvas (see the next section).
{:as :name} — declare a free arg explicitly
The most common way to surface a slot as a free arg is the
:as rename — it keeps the slot UNBOUND while giving it a
public name:
{:name :json-route
:parent :route
:args {:method "get"
:path {:as :path} ; :path is free
:handler {:as :handler}}} ; :handler is free
:json-route is now a TEMPLATE. Callers fill in :path and
:handler; everything else is baked in (:method = "get").
{:name :get-users-route
:parent :json-route
:args {:path "/users"
:handler :list-users}}
:get-users-route binds both free args. It has zero free args
of its own.
Seeing free args in the editor
Open any composed fn-card and look at the CANVAS, not a text
strip. Every free arg renders the same way — as a placeholder
edge ending in a small node with a binder button (+) you click
to bind it (a value, a ref, or a rename). That's the visual "this
slot is still open" signal, and it's where you fill a slot in
without leaving the graph.
Where the arg CAME FROM is a style gradation on that same shape, not a different UI: an arg propagated from deep inside the composition draws lighter and more sparsely dashed than a direct slot's, an optional one (the fn runs without it) is dimmed, and a λ-marked ghost edge is a lambda parameter an enclosing higher-order fn supplies per call — visible so the mechanics are legible, but not bindable. Hover any of them for the provenance.
Renaming one is a click on the arg's NAME on the incoming edge (the type chip beside it opens the type editor instead). The new name is what your callers — and the run form — see; the ancestor that declared the slot keeps its own name for it, because a rename is a view over the same slot, not a second slot. An arg that only got a rename stays unbound.
The run form mirrors them: open the row's ⋯ popover and click
▶ Run → the Run pane in the right panel has exactly one field per
free arg. A fn with no free args (every slot bound) shows "No
free arguments — click Run to invoke" instead of a form.
One thinner, informational strip can sit at the BOTTOM of the card — read-only, surfaced from the storage chain:
⇣name— deep-free args this fn accepts on the caller's behalf whose actual use-sites live deeper in the chain. Without it the card shows only the outgoing edge, and nothing on the card itself says "I take this name".
Optional and HOF-captured args need no strip of their own — they are the dimmed and λ-ghost edges you just met above.
Free args + HOF
Lesson 09 covers HOFs in detail; the short version of how they interact with free args:
For ITERATING HOFs (:map, :filter, …) the callback's
free args minus the structural lambda-param become free args of
the CALLER:
{:name :scale-each
:parent :map
:args {:func {:parent :mul
:args {:nums [{:as :item} ; iteration param
{:as :factor}]}} ; free → surfaces up
:coll {:as :nums}}} ; free → surfaces up
:scale-each's free args are :factor and :nums. The :item
slot doesn't surface — :map fills it per iteration.
For ONE-SHOT HOFs (:future, :assoc-fn, :invoke) the
callback's single non-env-bound free arg becomes the lambda's
input; everything else surfaces as a free arg of the caller.
Renaming a slot through the chain
Renames cascade. If :str-len's slot is :string, and an
ancestor renames to :input, and the grandchild renames again
to :text, the final caller sees :text. The runtime walks
source-slot-id to resolve back to the original slot identity
(see lesson 04).
This is what makes reusable templates ergonomic: the public name is what the caller sees, even if the inherited slot's original name was something internal.
A common gotcha — {:value :keyword} is a LITERAL, not a ref
{:name :almost-template
:parent :assoc
:args {:key {:value :foo}}} ; :key is BOUND to the keyword :foo
{:value :foo} is a literal — the keyword :foo. NOT a ref to
the fn-def named :foo. To make :key free, use {:as :foo}:
{:name :real-template
:parent :assoc
:args {:key {:as :foo}}} ; :key is FREE, exposed as :foo
Same shape on the wire, completely different semantics. The editor's chip color helps distinguish — literal chips and ref chips render differently — but in raw EDN this catches people out.
Try it
Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.
Run a template through its free arg, pin that arg in a child, then rename it in another:
- Type
to-jsonin the Explorer filter and click theto-json-stringrow (core.system). Its:dataedge ends in an open arrow with nothing bound — the slot is FREE. (The+that fills a slot appears only on a fn you own; you extend this one in step 3.) - Click
⋯on the row, then ▶ Run. The Run pane opens in the right panel with adatafield — the free slot surfaced as your input. Enter{"a": 1}and Run: the JSON string comes back. - Pin it in a child:
⋯ → Extend, name ittutorial-json, Save. The+placeholder on:datamoved onto your card. Click it, choose Bind literal, enter{"greeting": "hello"}, Save. - Run
tutorial-json(⋯ → ▶ Run → Run): no fields this time — the binding closed the slot, so callers have nothing left to supply. Bound beats free. - Now rename instead of binding. Select
to-json-stringagain,⋯ → Extend, name ittutorial-renamed, Save. On the edge running into the card, click the arg's NAME (data) — not the type chip beside it — typepayload, Save. The label on the edge changes. - The Run form on the right asks for
payloadnow, notdata(⋯ → ▶ Runopens it if the panel is on another tab). Enter{"a": 1}and Run. A rename is a VIEW over the same slot — the parent still knows it as:data, your callers seepayload.
Going further (fns.edn / MCP only)
The same three moves in one template chain, with a literal pinned alongside the renames:
{:name :tutorial-status-response
:parent :ring-response
:args {:status {:as :status}
:body {:as :body}
:headers {:value {"Content-Type" "text/plain"}}}}
{:name :tutorial-200-ok
:parent :tutorial-status-response
:args {:status 200
:body {:as :message}}}
:tutorial-status-response shows two placeholder nodes — status
and body — and two Run fields; Content-Type is baked in.
:tutorial-200-ok has just one placeholder, message: :status
is pinned to 200 and :body renamed to the public :message. Run
it with :message = "OK" for a Ring response map.
What we glossed over
- Optional vs required free args — slots can be marked
:required false. Optional frees default to nil; required ones must be supplied. A descendant may only make an optional slot required, never the reverse — Lesson 07 walks it on the canvas; TYPES.md has the required-narrowing monotonicity rule. - Type checking free args — the executor validates each
supplied value against the slot's declared type at call time.
Mismatch →
:validation-error/type-mismatch. Lesson 15. - Closure-capture — free args propagate through
:fn-typed slots in a way that requires special handling. Lesson 09.