01 / The idea
A script that runs every check is a fair start.
Your app emails a link when someone forgets their password, and that reset code needs
checks. It takes its mailer as a parameter, so a check can hand it one that keeps emails in
memory instead of sending them. runResetChecks builds that memory mailer, requests
a few resets, and writes pass or fail for each check. It reads top to bottom, needs no framework,
and anyone can follow it.
Read the first checksTypeScript · the version this lesson starts from
// The first version: one script owns the whole flow, calling each check in order.
export function runResetChecks(report: string[]): void {
const mailer = memoryMailer();
const resets = createPasswordResets({ mailer, tokens: memoryTokens() });
resets.request('mina@example.com');
report.push(mailer.sent.length === 1 ? 'pass: sends one email' : 'fail: sends one email');
resets.request('qa@example.com');
report.push(
mailer.sent.length === 1
? 'pass: sends one email per address'
: 'fail: sends one email per address'
);
resets.request('not-an-address');
report.push('pass: handles a mistyped address');
report.push(
mailer.sent[0].body.startsWith('https://app.example.com/reset')
? 'pass: links to the reset page'
: 'fail: links to the reset page'
);
} Go’s version panics where TypeScript throws, the way an unchecked error escapes a script. Both languages meet again at the runner in section 02.
Then it grows. The second check fails because it reads the first check’s email. A check for a mistyped address throws, and the report ends after two lines with nothing about the rest. CI wants to run only the link check, and every new check means editing the script’s order.
Inversion of control moves the flow out of your code. Instead of calling each step, you hand the steps to something else, and it decides when to call them, how often, in what order, and what happens when one fails. Your code gets smaller and more uniform; in exchange, it runs on someone else’s schedule and has to fit their contract. Martin Fowler calls it “a common characteristic of frameworks.”
Section 05 hands a virtual list your row renderer and lets it decide which rows to draw, in React and Svelte.
02 / See the shape
Register the parts, and let the runner call them.
The basic form is a runner: test() hands it a name and a function, and run() calls each one. In the wild the runner also calls setup
before every test and chooses which tests run. At the call site runs the script
and the suite on the same four checks.
Both languages produce the same results.
A runner. test() registers a name and a function; run() calls each one and records what it throws.
export type Result = { name: string; passed: boolean; error?: string };
// Register tests with test(); the runner decides when to call them, and catches what they throw.
export function createRunner() {
const tests: { name: string; fn: () => void }[] = [];
return {
test(name: string, fn: () => void): void {
tests.push({ name, fn }); // Registered, not run.
},
run(): Result[] {
return tests.map(({ name, fn }) => {
try {
fn();
return { name, passed: true };
} catch (error) {
return {
name,
passed: false,
error: error instanceof Error ? error.message : String(error)
};
}
});
}
};
} type Result struct {
Name string
Passed bool
Err string
}
// Runner registers tests with Test; Run decides when to call them and recovers what they panic with.
type Runner struct {
tests []namedTest
}
type namedTest struct {
name string
fn func()
}
func (r *Runner) Test(name string, fn func()) {
r.tests = append(r.tests, namedTest{name, fn}) // Registered, not run.
}
func (r *Runner) Run() []Result {
results := make([]Result, 0, len(r.tests))
for _, t := range r.tests {
results = append(results, call(t.name, t.fn))
}
return results
}
func call(name string, fn func()) (result Result) {
result = Result{Name: name, Passed: true}
defer func() {
if recovered := recover(); recovered != nil {
result = Result{Name: name, Err: fmt.Sprint(recovered)}
}
}()
fn()
return result
} Reading the TypeScriptRegistering and calling
test() pushes { name, fn } onto an array and returns.
Nothing about the reset code runs until run() maps over that array, calling
each fn inside its own try.
createSuite(setup) calls setup() right before each test and passes
the result in. Handing each test its fixtures is dependency injection; calling the tests is
inversion of control.
Reading the Gogo test already works this way
Go’s testing package is “intended to be used in concert with the ‘go test’
command, which automates execution of any function of the form func
TestXxx(*testing.T).” The lesson’s own runner_test.go is inverted: you never call TestSharedExpectedRun.
The small runner uses defer and recover so a panicking test becomes
a failed result instead of ending the run.
03 / Follow the calls
Watch who calls whom.
Five steps, each running the lesson’s code. On the left is every call in order, and who made it; on the right, what happened to each check. Before each step, guess which checks run.
In Try it, run the checks either way and ask for only some of them.
Who calls whom?
Your script calls every check. In control: runResetChecks, your code. Calls: script → request('mina@example.com'); script → request('qa@example.com'); script → request('not-an-address'). Results: sends one email: pass; sends one email per address: fail; handles a mistyped address: fail (threw “invalid address”; the script stopped); links to the reset page: not run. The second check saw the first check’s email, and the third threw “invalid address”. Nothing after it ran.
Your script owns the flow.
runResetChecks calls each check in order. The second shares the first one’s mailer, and the third throws and stops the rest.
Reduced motion: choose a scene to see its completed state.
Read this scene
runResetChecks calls each check in order. The second shares the first one’s mailer, and the third throws and stops the rest.
Your script calls every check. In control: runResetChecks, your code. Calls: script → request('mina@example.com'); script → request('qa@example.com'); script → request('not-an-address'). Results: sends one email: pass; sends one email per address: fail; handles a mistyped address: fail (threw “invalid address”; the script stopped); links to the reset page: not run. The second check saw the first check’s email, and the third threw “invalid address”. Nothing after it ran.
Watch restarts when you return. Step through keeps your selected step. Try it starts with the runner and every test each time you open it.
What handing over the flow buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Failures that stay contained
- The mistyped address fails its own test, and the link test after it still runs.
- Fresh fixtures every time
- The runner calls setup before each test, so no test reads another test’s email.
- Choosing what runs, without editing tests
run({ only: "link" })picks one test from the outside.- Every part has the same shape
- Each test is a name and a function that receives fixtures.
- New parts without touching the flow
- Adding a fifth test is one more
test()call, not an edit to an ordered script.
The review words are inversion of control, framework versus library (you call a library; a framework calls you), callback, hook for setup, and the Hollywood principle: “don’t call us, we’ll call you.” Section 08 covers
what they cost.
04 / Try a decision
A check that runs before its test.
A file registers a test and checks its result on the next line. The code is in collected.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
A list that decides which rows to draw.
The admin area shows every password-reset request, tens of thousands of them. Drawing every row would freeze the page, so a virtual list draws only the rows in view and swaps them as you scroll. You write what one row looks like; the list decides which rows exist and when to draw them.
Owns the flow
Scrolling, which rows are in view, and when to draw them.
Called for visible rows
Many times, for any row, in whatever order the list chooses.
Handed over whole
The list slices it; the row sees one item.
The example leaves out variable row heights, keyboard navigation through off-screen rows, and loading more data as you scroll.
Build UIs?Every component and handler you write is called by the framework, and one day a library calls your code more often, or less, than you assumed.
Where it already is in your components
React’s docs put it plainly: “Rendering” is React calling your components. You never write ResetRow(); you return <ResetRow /> and React decides when to
call it. A click handler is handed over the same way, and called when someone clicks.
In Svelte you write the markup for a row inside {#each}, and Svelte runs
it for each item, and again when the list changes.
When you have to own it
Now it’s the admin log. The React version passes a renderRow function; the
Svelte version passes a snippet, and Svelte’s docs note that snippets “are values just
like any other. As such, they can be passed to components as props.” Either way, the list
calls your renderer only for the rows in view. Both lists ask visibleRange, shown below with the Svelte list, which rows those are.
That’s the contract you accept: the renderer may run for any row, many times, in any order. Keep it to drawing one row, and don’t count on how often it’s called.
// Which rows a virtual list renders: the ones in view, plus a few either side.
export function visibleRange(options: {
total: number;
rowHeight: number;
viewportHeight: number;
scrollTop: number;
overscan?: number;
}): { start: number; end: number } {
const { total, rowHeight, viewportHeight, scrollTop, overscan = 2 } = options;
const first = Math.floor(scrollTop / rowHeight);
const visible = Math.ceil(viewportHeight / rowHeight);
return {
start: Math.max(0, first - overscan),
end: Math.min(total, first + visible + overscan)
};
}
export type ResetRequest = { id: string; address: string; requestedAt: string };
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { visibleRange } from '../visible-range';
// A small virtual list. It owns scrolling and decides which rows exist; it renders the row
// snippet for those.
let {
items,
rowHeight,
height,
row
}: { items: T[]; rowHeight: number; height: number; row: Snippet<[T, number]> } = $props();
let scrollTop = $state(0);
const range = $derived(
visibleRange({ total: items.length, rowHeight, viewportHeight: height, scrollTop })
);
</script>
<div
style:height="{height}px"
style:overflow-y="auto"
onscroll={(event) => (scrollTop = event.currentTarget.scrollTop)}
>
<div style:height="{items.length * rowHeight}px" style:position="relative">
{#each items.slice(range.start, range.end) as item, offset (range.start + offset)}
<div
style:position="absolute"
style:top="{(range.start + offset) * rowHeight}px"
style:height="{rowHeight}px"
>
{@render row(item, range.start + offset)}
</div>
{/each}
</div>
</div>
A reset-history list whose row component and click handler are handed to the framework, which calls them.
import type { ResetRequest } from './visible-range';
// You write ResetRow, but you never call it. React calls it for each item when it renders.
function ResetRow({ request }: { request: ResetRequest }) {
return (
<li>
{request.address} <time>{request.requestedAt}</time>
</li>
);
}
export function ResetHistory({
requests,
onResend
}: {
requests: ResetRequest[];
onResend: (id: string) => void;
}) {
return (
<ul>
{requests.map((request) => (
<ResetRow key={request.id} request={request} />
))}
{/* Handing over a function: React calls it when the button is clicked, not now. */}
<button type="button" onClick={() => onResend(requests[0].id)}>
Resend the latest link
</button>
</ul>
);
}
06 / Recognize it elsewhere
Anywhere you hand over a function instead of calling it.
You’ve met all of these. For each one, find what you hand over and who calls it.
| Where you’ve seen it | What you hand over | Who calls it, and when |
|---|---|---|
test('…', fn) | The test body | The test runner, after collecting every test |
addEventListener('click', handler) | The handler | The browser, on each click |
| A route handler registered with a router | The handler | The router, once per matching request |
| A component | The component function or markup | The framework, whenever it renders |
items.map(callback) | The callback | map, once per element, right now |
The last row is the small end of the same idea: map owns the loop. The larger the
thing that calls you, the more of the flow you’ve handed over.
07 / Already in your toolbox
Your tools already call you.
Three places to look. For each one, find when your code runs and who decides.
Martin Fowler · Inversion of Control Containers and the Dependency Injection pattern
Where the distinction between inversion of control in general and dependency injection in particular was drawn, in 2004.
Read the article ↗Jest · Setup and Teardown
The order a runner calls your code in, including that it collects every describe block before it runs any test.
Go · Package testing
How go test finds and calls your TestXxx functions, and what t.Fatal and t.Error tell it.
A useful counterexample: a library you callWhen nothing is inverted
Formatting the reset email is a function you call, with arguments you choose, when you choose. Keep it that way. Not every helper needs to be a hook.
08 / The parts to watch
Your code runs on someone else’s schedule.
These are the places it still goes wrong.
Registering isn’t running
Code next to test() runs when the file loads; the test body runs later. Jest “executes
all describe handlers in a test file before it executes any of the actual tests.”
The call site isn’t in your code
A stack trace from inside a test or a row renderer starts in the framework. Reading the flow means reading its docs, not your file.
Their contract is now yours
The signature, whether a function may be async, and what a throw means are decided by the thing that calls you. A throw here fails one test; in a route handler it might be a 500.
Order you didn’t choose
This runner keeps registration order. Runners that run tests in parallel or shuffle them won’t, so a test that depends on another test is waiting to fail.
State outside the hooks still leaks
Fresh fixtures only help when they come from setup. A mailer created at the
top of the file is shared again.
You may be called more often than you think
Renderers and components can run many times for one screen. Keep what they do safe to repeat, as in Pure functions and side effects.
09 / Make the call
What would you have to change tomorrow?
Give both versions a plausible change and follow the work it creates.
| The change | Your script | A runner |
|---|---|---|
| A migration that runs once | Reads top to bottom. | Ceremony for one run. |
| One check throws | Everything after it is lost. | Recorded; the rest run. |
| Run only the link check | Edit the script. | Pass a filter. |
| Each check needs a clean mailer | Build one by hand in each. | Setup does it. |
| Step through one failure | One function, top to bottom. | A breakpoint in a callback the runner calls. |
Hand over the flow when many small pieces share it: tests, routes, rows, handlers. A fourth check with its own fixtures is the moment.
Keep the flow in your code when you run it once and want to read it top to bottom.
The question I’d leave beside the code is: who calls this, when, and what do they expect back?
10 / Take the idea with you
Explain the half-finished report without saying “inversion of control.”
“Our script called every check itself, so when one threw, it stopped and nothing said what happened to the rest. We gave the checks to a runner. It calls each one, gives it a fresh mailer, and records what fails.” In a review, the words are inversion of control, framework, callback, and hook.
Before moving on, jot down why the report stopped after two lines, why the top-level check saw -1, and one function in your own code that something else calls.
Connections to follow nextRelated lessons
- Dependency injection is the other half of this code: who supplies the mailer, not who calls the tests.
- Template method keeps the flow in a base sequence and calls the steps you fill in.
- Observer hands a subject your listener, which it calls when something changes.
- Dependency direction asks which of this code imports which, even when the calls go the other way.