skip to content

Why may a compiler-hosted processor add a companion type but not edit the declaration that triggered it?

level: seniorimportance: should knowfreq 50%

answer

  1. add, never amend
  2. already resolved when you see it
  3. order would become semantics
  4. companion or generated supertype
  5. rewriting output leaves the model

basics

~20 s

The marked declaration has already been parsed and resolved, and other code in the same compilation has been checked against it. Rewriting it mid-build would invalidate work already done, so the model allows only additions: new files, never edits.

solid answer

~50 s

By the time a processor is called, the marked declaration is not text any more — it is a resolved symbol, and other declarations in the same compilation have already been type-checked against it. If a processor could add a member to it, everything resolved earlier would be based on a type that no longer exists, and the result would depend on the order the processors happened to run in. The model avoids that by being **monotonic**: rounds add declarations and never amend them. The workarounds all move the change to something new — a companion type the caller names, a generated supertype the author's type is declared to extend, a generated part the language lets the author merge with — or leave the compiler's model entirely by rewriting the compiled output, which gives up source-anchored diagnostics.

code

pseudocode · 14 lines
pseudocode
// what the processor may NOT do: reopen the marked declaration
marked with Mapped
class Order:
    field id
    field total
    // <- a processor cannot insert toMap() into this body

// what the processor MAY do: emit a separate file beside it
generated file "OrderMapper":
    function toMap(order):
        return { "id": order.id, "total": order.total }

// so the call site names the generated companion, not the marked type
row = OrderMapper.toMap(order)

go deeper

for a junior

Recall the shape of the result: the marker gets you a new type next to yours, not a new method inside yours, so your calling code has to name the generated one.

for a middle

Explain why: the declaration is already resolved and others have been checked against it, and several processors may run in one round, so edits would make order into semantics.

for a senior

Choose between the workarounds for a real codebase and justify it — companion plus forwarder, generated supertype, or leaving the model for a post-compile rewrite — and say what each does to diagnostics and to drift.

for a principal

Decide what your organisation is allowed to do when a team wants in-place edits: whether the reproducibility and readability the add-only rule buys is worth more than the boilerplate it leaves, and who owns any step that leaves the model.

## The rule A processor's output surface is **new files**. It can read the marked declaration in detail — its name, kind, modifiers, members, the values written into the marker — and it can emit as many new declarations as it likes. What it cannot do is reach back into the declaration it was triggered by and add, remove or change a member. That is the constraint every candidate has bumped into: *the marker is on my type, why did the tool put the generated method on a second type instead of on mine?* ## Why the model forbids the edit Three reasons stack up, and naming any two is a good answer. 1. **The declaration is already resolved.** The processor is not handed text; it is handed a symbol the compiler has already parsed, whose signatures have been resolved. Other declarations in the same compilation have been checked against that symbol. An edit would retroactively falsify checks that have already passed. 2. **Ordering would become semantics.** Several processors may be dispatched in one round. If each could mutate declarations, the resulting program would depend on the order they happened to run in — an order no source file states and no build tool guarantees. Add-only output is order-independent: whoever runs, the set of declarations only grows. 3. **Rounds are monotonic.** The loop that feeds each round's output into the next one relies on earlier declarations staying put. A declaration a processor inspected in round one is still the same declaration in round four. Mutation would mean a processor could observe a type that no longer exists by the time it emits against it. ## The workarounds, and what each costs | approach | what the author writes | what it costs | |---|---|---| | companion type beside the marked one | nothing, but call sites name the companion | the generated name leaks into every caller | | generated supertype the marked type extends | one declaration on their own type | spends the inheritance slot where a language has only one | | generated interface plus a hand-written one-line forwarder | one line per marked type | boilerplate the marker was meant to remove | | language-level partial or extension merging | a keyword on their type | only exists in some languages, so a shared tool cannot rely on it | | rewriting compiled output after the build | nothing | leaves the compiler's model: no source-anchored diagnostics, and another build step to own | The first two are the common ones. The last is the one teams reach for when they want the edit badly enough to leave the mechanism, and it is worth naming as a different mechanism rather than as a variant of this one. ## What the constraint actually buys It is easy to read the rule as a limitation, but it is the reason the model is safe to put in a shared build: - **You can read the source and know what a type declares.** A member you cannot find in the file does not exist on that type — a property that survives exactly as long as nobody can inject members. - **Results are reproducible.** With order-independent, add-only output, the same inputs produce the same outputs regardless of which processors were installed in which order. - **Failure is local.** A processor that goes wrong produces a bad new file, which fails to compile with an error pointing at that file, rather than corrupting a type that a hundred call sites depend on. ## The failure to recognise The characteristic bug is not an error but an absence. A team applies a marker expecting a method on their type, finds no method, and concludes the tool is broken. Nothing is broken: the tool generated a companion, and the call site has to name it. The second-order version is worse — a hand-written forwarder that drifts, because the generated companion gained a parameter and the one-line forwarder was written by hand and never regenerated. A processor author mitigates both by making the generated surface discoverable: a predictable name derived from the marked declaration, a generated supertype where the language and the team's inheritance budget allow it, and a diagnostic at the marked declaration whenever the tool decides not to generate. ## What this does not cover The rule is about the processing model, not about what any particular toolchain has bolted on. Some ecosystems ship mechanisms that *appear* to add members to a marked type; where that happens, the mechanism is not a processor obeying this model but a different one operating at another phase, with a different set of guarantees about diagnostics, incremental builds and what an editor can see. The distinction is worth drawing explicitly in an interview, because it is exactly the line between the model described here and the tools that break it.

  • If the edit is forbidden, how do tools appear to add a method to the type you marked?
    They are not processors obeying this model. Either the language itself offers a merge mechanism the author opts into on their own declaration, or a separate step rewrites the compiled output after the compiler has finished. Both change the guarantees: the second in particular gives up source-anchored diagnostics and adds a build step someone must own.
  • Why does forbidding edits make a build reproducible?
    Because add-only output is order-independent. Several processors may run in one round in an unspecified order; if each could mutate declarations, the program would depend on that order. When every processor can only add, the resulting set of declarations is the same whichever order they ran in.
  • What is the hidden cost of the hand-written one-line forwarder into a generated companion?
    Drift. The forwarder is hand-written, so it does not regenerate when the companion's signature changes; the build then fails at the forwarder rather than at the marked declaration, or worse, still compiles against a stale overload. A generated supertype avoids the drift at the price of the inheritance slot.

saying these in an interview costs you the question

  • Claims a processor can rewrite the body of the marked declaration
  • Says the restriction is arbitrary rather than protecting resolved symbols
  • Thinks a later round may re-emit a generated file with new content
  • Assumes a generated supertype is free of any inheritance cost
  • Treats post-compile rewriting as the same mechanism with the same guarantees
  • Believes the companion's name can be hidden from every call site