skip to content

During review you find a run-time type test inside a routine declared over an unknown entry type — what reasoning does it destroy?

level: seniorimportance: should knowfreq 44%

answer

  1. the compiler was the reviewer, now you are
  2. behaviour may vary per type argument
  3. the commuting rewrite is no longer safe
  4. a future type argument is an untested path
  5. proof downgraded to a promise

basics

~20 s

Every conclusion that came from the declaration alone. Once the body can ask what the type is, the guarantee stops being a proof the type system enforces and becomes a promise a reader must check, for each type argument.

solid answer

~40 s

Three things go at once. The count of possible behaviours collapses: the body may now do something different for each type argument, so the declaration no longer bounds what it does. The free theorems fail: transforming entries before the routine can change which branch fires, so that rewrite is no longer safe. And local reasoning fails: a reviewer at a call site can no longer read the declaration and stop, because the behaviour depends on a type argument that call site chose. What replaces the proof is ordinary diligence — documentation of the per-type behaviour, a test per supported type argument, and someone remembering all of it when a new type argument appears. The routine may still be perfectly correct; the point is that its correctness is no longer derivable.

code

pseudocode · 8 lines
pseudocode
// signature still says: draw(entries: list of T) -> list of T
function draw(entries)
    if size(entries) > 0 and is_text_like(entries[0])
        return sorted_copy(entries)     // one behaviour for one kind of entry
    return reversed_copy(entries)       // another for everything else

// transform text entries into numbers first and the other branch fires:
// transform_each(f, draw(xs)) is no longer equal to draw(transform_each(f, xs))

go deeper

for a junior

Remember the headline: once a routine looks at what type it was handed, you can no longer tell what it does by reading its declaration. You have to read the body.

for a middle

Explain the mechanism — the declaration bounded the behaviours because the body was blind, and a run-time test removes the blindness, so the bound and the rewrites that rested on it both go.

for a senior

Show the operational consequence you have lived with: the untested branch a future type argument falls into, and the per-type test matrix and documentation that inspecting code now needs.

for a principal

Weigh it as a policy question: how much review capacity across teams you are spending for the convenience of one branch, and whether the decision belongs in the caller's hands instead.

## A proof becomes a promise A routine declared over a type it does not name carries a guarantee the type system enforces at compile time: whatever the body is, it cannot behave differently for different type arguments, because nothing tells it which one arrived. A **run-time type test** inside the body — asking what the entry actually is and branching on the answer — removes the premise. Everything the declaration used to prove it now merely claims. Nothing about this is automatically a defect. A body that inspects may be exactly right for the job. What changed is *who is responsible for knowing that*: the compiler was, and now a person is. ## What is lost, in order 1. **The bound on possible behaviours.** Before the test, the body could only choose slots, and the choice depended on the length. After it, the body can do one thing for one kind of entry and something else for another, without limit. There is no longer a small set of implementations the declaration allows. 2. **The free theorems.** The commuting law — transform every entry then pick, versus pick then transform — depended on the routine not seeing entries. A transformation can turn entries of one kind into entries of another and so flip the branch the test selects. The two pipelines can now return different results. 3. **Local reasoning at the call site.** A reviewer used to read the declaration and be finished. Now the behaviour is a function of the type argument supplied at that particular call, so the review question changes from "is the declaration right?" to "is this routine right for *this* type, and has anyone checked?" | | Before the test | After the test | |---|---|---| | Possible behaviours | one rule over positions | one per type the test can distinguish | | Transform-then-pick rewrite | guaranteed safe | must be re-established by hand | | Reviewing a call site | read the declaration | read the body, with this type in mind | | A new type argument | provably no new path | a new, untested path | | Documentation needed | the declaration is the documentation | per-type behaviour must be written down | ## The cost that arrives later The expensive consequence is the last row. The day the test is written, every type argument in the codebase is known and presumably handled. The routine is then used with a type nobody considered — often by another team, often years later — and it silently takes the fall-through branch. The compiler has nothing to say: the declaration still accepts that type, because the declaration never mentioned the test. Failures of this shape are hard to trace precisely because the declaration reads as though it promises uniformity. This is also why the phrase **"downgrading a proof to a promise"** is the right one. The behaviour is not less true today. It is less *checkable*, and it stops being rechecked automatically on every change. ## What to do about it in review The useful review response is not "remove the test". It is to ask which of these applies: - **The decision belongs to the caller.** Hoist it: take the differing operation as an argument, so the caller supplies the behaviour rather than the body guessing it from the type. The declaration becomes honest again, and it once more says everything there is to say. - **The routine is genuinely type-directed.** Then it should stop pretending to be uniform — name it for what it does, document the behaviour for each type it handles, and say what happens for a type it does not. - **The test is a shortcut around a missing argument.** Very common: the body needs one extra piece of information and gets it by guessing from the type instead of asking for it. Whichever applies, the test matrix has to change: uniform code is correct for every type argument once it is correct for one, and inspecting code is not. ## The interviewer's real target This question separates people who think of types as annotation from people who think of them as leverage. The strong answer names what the declaration *used* to buy — a bounded set of behaviours, rewrites that are safe without reading the body, and a review that covers type arguments nobody has written yet — and then says which of those the test spends. The weak answer says the test is "bad practice" without being able to name a single thing it costs.

  • Is the routine now wrong?
    Not necessarily — it may be exactly what was wanted. What changed is that its correctness no longer follows from the declaration, so it has to be established by documentation and tests and re-established whenever the body or the set of type arguments changes.
  • How would you keep the behaviour but restore the guarantee?
    Move the decision to the caller. Take the differing operation as an argument, so the body applies whatever it is given instead of guessing from the type. The routine goes back to being uniform, the branch becomes a value someone chose explicitly, and the choice is visible at the call site.
  • Which failure shows up latest?
    A type argument nobody anticipated. The declaration accepts it because the declaration never mentioned the test, so the call compiles and quietly takes the fall-through branch — often in another team's code, long after the branch was written.

saying these in an interview costs you the question

  • Says the test makes the routine unsafe or broken outright
  • Thinks the declaration still bounds what the body can do
  • Claims the commuting rewrite is unaffected by the branch
  • Assumes a new type argument cannot reach the fall-through branch
  • Says nothing extra needs documenting because the declaration is unchanged