skip to content

When a type system's placeholders cannot stand for a wrapper itself, what do teams write instead of one pipeline per wrapper?

level: seniorimportance: should knowfreq 40%

answer

  1. the return type is the unwritable part
  2. duplicate, normalise, generate, or encode
  3. a marker type of ordinary kind
  4. lift at the edge, lower at the edge
  5. encoding costs inference and error messages

basics

~20 s

They normalise every stage result onto one chosen wrapper at the boundary, encode the wrapper as a marker type with lift and lower conversions, or generate the per-wrapper copies from a single source. Each trades the missing abstraction for a different cost.

solid answer

~40 s

The wall is concrete: if every placeholder must stand for a finished type, the signature `runStage<W, A, B>(input: W<A>, step: A -> B) -> W<B>` cannot be written, because the return type has to re-parameterise the caller's wrapper. Four answers are in real use. **Duplicate** the pipeline per wrapper and pay for drift. **Normalise** at the boundary — convert every stage result into one chosen wrapper and write a single concrete pipeline. **Generate** the copies from one template so the source stays single. Or **encode** the wrapper: give each one a marker type of ordinary kind, add a type-level lookup from marker and element to the concrete wrapped type, and `lift`/`lower` conversions at the edges. The encoding is the only one that recovers the abstraction, and it costs readability, inference quality and error messages.

code

pseudocode · 18 lines
pseudocode
// Stopped at simple parameters: one copy per wrapper
function runStage(input: MaybePresent<A>, step: function(A) -> B) -> MaybePresent<B>
function runStage(input: ListOf<A>,       step: function(A) -> B) -> ListOf<B>
function runStage(input: SuccessOrFail<A>,step: function(A) -> B) -> SuccessOrFail<B>

// Encoding: an empty marker stands for the wrapper, a lookup names the real type
type MaybeMarker            // no fields, no values, only ever named
type Wrapped<M, A>          // marker + element -> the concrete wrapped type

function lift(value: MaybePresent<A>) -> Wrapped<MaybeMarker, A>
function lower(value: Wrapped<MaybeMarker, A>) -> MaybePresent<A>

// One body again, with the wrapper's operations passed in as a value
function runStage<M, A, B>(input: Wrapped<M, A>,
                           ops: Mapper<M>,
                           step: function(A) -> B) -> Wrapped<M, B> {
    return ops.mapOver(input, step)
}

go deeper

for a junior

The takeaway is smaller than the machinery: some abstractions a codebase seems to be missing are not written because the type system cannot express them, not because nobody thought of it.

for a middle

Be able to point at the exact unwritable part of the signature — the return type that must re-parameterise the caller's wrapper — and list two workarounds with their costs.

for a senior

Show you have picked one in production. Say which wrappers you normalised, what the conversion discarded, and what evidence would make you move to the encoding instead.

for a principal

Own the ladder for the whole codebase: which rung the team stands on, what event moves it up, and whether the encoding's cost in diagnostics and onboarding is one you are prepared to standardise on.

## The wall, stated plainly A validation pipeline wants one body for three shapes of stage result: a maybe-present value, a list of values, a success-or-failure value. That body needs the signature `runStage<W, A, B>(input: W<A>, step: A -> B) -> W<B>`. If the type system's placeholders may only stand for finished types — kind `*` and nothing else — then `W` is unwritable, and the reason is the *return* type: the pipeline must hand back **the caller's own wrapper** re-parameterised, which no element-only placeholder can express. Recognising that this is a limit of the type system, and not a limit of your imagination, is what the question is really testing. ## The four answers in real use | Workaround | What you actually write | What it costs | |---|---|---| | Duplicate per wrapper | one copy of the pipeline for each wrapper | drift between copies; every new stage edited N times | | Normalise at the boundary | converters into one chosen wrapper, then one concrete pipeline | information the chosen wrapper cannot carry; conversions at every edge | | Generate the copies | one template plus a build step emitting N bodies | generated code in reviews and stack traces; a tool to own | | Encode the wrapper | marker types, a type-level lookup, lift/lower, an operations value | dense signatures; weaker inference; error messages about the encoding | Normalising is the one most teams underrate. If two of the three wrappers appear only at the system's edge, converting them once and running a single concrete pipeline is cheaper than any abstraction — the cost is exactly the information the chosen wrapper cannot represent, such as several accumulated failures collapsing into the first one. ## The marker-type encoding, step by step This is the workaround worth being able to describe, because it is what libraries do when they must offer wrapper-generic code on a platform that has no parameter of kind `* -> *`: 1. Declare an empty **marker type** of ordinary kind for each wrapper. It holds no data and no values are ever made of it; it exists only to be named in a type position. 2. Declare a **type-level lookup** — write it `Wrapped<M, A>` — that maps a marker plus an element type onto the concrete wrapped type. How, or whether, this is expressible varies between type systems; some offer member types on the marker, others offer nothing and the lookup degenerates into an opaque type. 3. Give each wrapper a **lift / lower pair**: `lift` turns a concrete wrapped value into `Wrapped<M, A>`, `lower` turns it back. In many systems the pair is implemented by a conversion the checker cannot verify — safe by construction because only that wrapper's own pair is ever used, but unverified. 4. Write the pipeline **once** against `Wrapped<M, A>`, taking alongside it a value carrying the operations the body needs on that wrapper. 5. Callers lift on the way in and lower on the way out; the edges carry the ceremony so the middle does not. What you get back is a real abstraction: one pipeline body, one place to add a stage. What you pay is that every signature on that path now mentions a marker, inference often stops helping because the checker cannot work backwards through the lookup, and a mistake surfaces as an error about the encoding rather than about the code the reader wrote. ## Costs worth saying out loud - **Readability**: the encoding is a second vocabulary a new joiner must learn before reading the pipeline at all. - **Inference**: the more indirection between the marker and the concrete wrapped type, the more type arguments callers must write by hand. - **Diagnostics**: an error message names the encoding's types, not the wrapper the caller passed. - **Blast radius**: once a helper is encoded, everything that calls it tends to become encoded too. - **Verification gap**: where lift and lower rest on an unchecked conversion, the compiler is no longer the thing keeping the pair honest — a test or a convention is. ## Naming the limit honestly The reason interviewers reach for this leaf is to hear a candidate say the limit out loud without either blaming the platform or pretending the abstraction exists. The strong answer sounds like: *this type system has no parameter that itself takes a parameter, so the one-body-per-wrapper signature cannot be written here; we normalised two of the three wrappers at the boundary and kept a single concrete pipeline, and if a fourth wrapper appears we will revisit the encoding.* That sentence shows you know the mechanism, the workaround ladder and the point at which each rung stops paying.

  • Why does the duplicated version usually drift rather than stay in step?
    Because the copies are only related by convention. A new stage, an ordering fix or a short-circuit rule is applied to the copy the author was working in, and nothing in the build notices the other two. Drift appears first as behaviour that differs by wrapper — the list variant keeps validating after a failure that the success-or-failure variant stops on.
  • When is normalising at the boundary the better answer even though it loses information?
    When one wrapper already dominates the interior and the others appear only at the edges. Converting at the edge keeps the core concrete and readable, and the loss is bounded and visible at a named conversion. It is the wrong answer when the discarded information is the point — collapsing accumulated failures into the first one, for instance, changes what the pipeline reports.

saying these in an interview costs you the question

  • Says a common supertype over the wrappers solves it.
  • Claims the duplication is unavoidable and stops there.
  • Treats the marker type as something values are made of.
  • Thinks the encoding costs nothing but a little ceremony.
  • Blames the language instead of naming the missing parameter shape.