skip to content

A shared policy library's public types: when do you commit to a declared direction rather than narrow at each signature?

level: principalimportance: should knowfreq 30%

answer

  1. a published direction is a one-way door
  2. implementers break, plain callers do not
  3. no opposite-position member, ever again
  4. removal breaks every consumer at once
  5. supertype carries it, full type stays invariant

basics

~20 s

Commit where the type is genuinely one-directional and will stay so, because a published direction is a one-way door: it constrains the type forever and can break outside implementers. Narrow per signature where the type reads and writes.

solid answer

~50 s

Treat a declared direction as a published promise, not a convenience. Adding one is mostly safe for plain callers - it only adds subtyping relations, so nothing that compiled stops compiling - but it can break any outside code that implements or extends the type with a member using the parameter in the forbidden position, and those implementers are the population you cannot see. It also closes the type's future: no such member may ever be added. Removing it later is the genuine break, because every consumer assignment that came to rely on the substitution fails at once, with no gradual rollout. So commit where the type is one-directional by design - a feed, a sink, an audit log - and prefer narrowing where it reads and writes. Where most signatures want the same view of a two-directional type, split a one-directional supertype out instead.

code

pseudocode · 12 lines
pseudocode
// the promise lives on a small type that can keep it
type ReadableRules<produces T> {
    each(): Sequence<T>
}

// the full type stays invariant and is free to grow in both directions
type RuleStore<T> is ReadableRules<T> {
    add(rule: T)
}

function audit(r: ReadableRules<Rule>)   // a RuleStore<StrictRule> is accepted here
function refill(s: RuleStore<Rule>)      // unchanged: no promise was made about RuleStore

go deeper

for a junior

Recall the asymmetry in one line: a direction on the declaration is published and permanent, while a narrowing at a signature is local and can be taken back.

for a middle

Explain the mechanics behind the asymmetry - the declaration is checked against every member and inherited by every occurrence, so it constrains the type's future as well as its present.

for a senior

Show what you would check before committing: which members use the parameter, who implements the type from outside, and whether consumers rebuild on your schedule or their own.

for a principal

Own the standard rather than the type: decide directions before first publication, keep two-directional types invariant with a review rule, and treat a post-publication direction as an API change.

## What a declared direction commits you to Writing a direction on a public type's parameter is not a local edit. It publishes three things at once: a subtyping relation other people's code may start depending on, a constraint on every member the type will ever have, and an expectation that the relation will keep holding. A narrowing at a signature publishes none of those - it is one parameter's business and can be withdrawn in the same release it appeared in. That asymmetry, not the keystrokes, is what the decision is about. ## Who actually breaks when you add one The common fear - that granting more subtyping breaks existing code - is mostly unfounded, and the real exposure is elsewhere: - **Plain callers do not break.** The change only adds assignments that were previously rejected. Everything that compiled still compiles. - **Implementers and extenders can break.** Outside code that implements the type is now checked against the direction, and any member of theirs that puts the parameter in the forbidden position stops compiling. You cannot enumerate these from inside your own build. - **The type's future breaks quietly.** Once the direction is published you may never add a member using the parameter the other way - every later feature that needs one becomes a new type, a sibling interface, or a breaking release. - **Existing narrowings go slack.** Occurrences that were narrowed by hand become redundant. They usually keep compiling, and they keep suggesting to readers that the declaration is still invariant. - **Already-compiled consumers see it at different moments.** Some platforms record the direction in the type's metadata, so downstream code must be rebuilt to see it; others expand it into each use site, so code compiled earlier keeps the view it was compiled with. Know which one you are on before you promise anything about the rollout. ## Removal is the one-way door Adding a direction is an additive change for callers; taking one away is not. Every assignment across every consumer that relied on the substitution fails the moment the direction disappears, and there is no partial rollout - the relation either holds or it does not. There is no deprecation period for a subtyping relation, because nothing can be marked as "still allowed but discouraged" in the type checker. Plan on never removing it. ## The questions to answer before committing 1. Does the parameter sit in one direction across **every** member of the type today, and is that a property of what the type is - or an accident of what it does so far? 2. How many consumers **implement** the type rather than merely calling it? Those are the ones a new direction can break, and they are usually invisible from your own build. 3. Is the flexibility wanted at one signature or at most of them? One signature is a narrowing; most signatures is a declaration or a missing type. 4. Can every consumer be rebuilt together, or do some build against a published artifact on their own schedule? 5. If the type genuinely reads and writes, is a one-directional supertype worth an extra name in the public surface? ## The middle road Question five is the one teams skip. A type that both produces and consumes can never carry a direction, but the **reading half of it** usually can. Publishing a one-directional supertype and letting the full type remain invariant beneath it gives the common path - auditing, copying out of, summarising - the free substitution, while the full type promises nothing and stays free to grow in both directions. The price is one more name to document and a migration of existing signatures onto it. | choice | reversible | breaks outside implementers | fixes future signatures | |---|---|---|---| | narrow at each signature | yes | no | no | | declare on the type parameter | no, in practice | yes, possibly | yes | | split a one-directional supertype | yes, until adopted | no | yes, for that supertype | ## Setting it as a standard For a library many teams build against, the decision is worth writing down once rather than relitigating per type: - decide the direction of a one-directional public type **before first publication**, when the promise costs nothing and breaks nobody; - leave genuinely two-directional types invariant, and require every new signature over one to state the view it needs, as a review rule - because nothing in the build will catch the omission; - treat adding a direction to an already published type as an API change with an owner and a consumer count, not a cleanup; - when the same narrowing appears in more than a handful of signatures, raise the split instead of adding the narrowing again. The principal-level answer is not which form is better. It is that one of them is a promise with no exit and the other is a local choice, and the library's standard should say which types are allowed to make promises.

  • If adding a direction only adds subtyping relations, why treat it as a breaking change at all?
    Because the check it turns on applies to outside implementers of the type, whose members are now validated against the direction. Anyone whose implementation uses the parameter in the forbidden position stops compiling, and that population is invisible from your own build. Callers are safe; implementers are the exposure.
  • What makes removing a direction harder to stage than adding one?
    A subtyping relation cannot be half-withdrawn. There is no way to mark it discouraged-but-allowed, so every consumer assignment that came to depend on it fails in the same release. Additions can be adopted gradually; removals land everywhere at once, which is why the commitment is worth making carefully.
  • Your library has the same narrowing repeated in a dozen signatures over one invariant type. What is that telling you?
    That the public surface is missing a type. A dozen signatures asking for the same one-directional view is a one-directional type that was never published. Splitting it out gives those signatures the substitution for free and leaves the full type invariant, without promising anything about the half that also writes.

saying these in an interview costs you the question

  • Assumes a new direction cannot break anyone because it only adds subtyping
  • Plans to remove the direction later if it does not work out
  • Counts the library's callers but never its outside implementers
  • Promises a direction on a type that also accepts the parameter
  • Treats the choice as a style preference with no API consequence