A captured claude --print session against the demo app, with a PostToolUse hook on Edit|Write that runs npm run typecheck after every edit and pipes any errors back as feedback. The prompt asked for a multi-file change; Claude completed the work in eight edits and incidentally fixed a pre-existing noUncheckedIndexedAccess error that the hook flagged on the way through.
The setup
The demo’s .claude/settings.json now registers three hooks: SessionStart (additive context, see article #36), PreToolUse (path-frozen denial, see article #37), and PostToolUse (this article):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/typecheck-after-edit.sh"
}
]
}
]
}
}
The script runs npm run typecheck and emits feedback via stderr if errors appear:
#!/usr/bin/env bash
set -euo pipefail
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}"
output=$(npm run typecheck 2>&1 || true)
if printf '%s\n' "$output" | grep -q "error TS"; then
cat >&2 <<MSG
Typecheck failed after this edit. The next turn must fix these
errors before any further edits land. The hook will re-run after
each subsequent edit until the project typechecks clean.
$(printf '%s\n' "$output" | grep -E "error TS|^\s+[0-9]+" | head -30)
MSG
exit 2
fi
exit 0
Exit code 2 + stderr is the canonical “feedback to the model” pattern for PostToolUse. The tool already ran (the edit landed); the hook cannot undo it, but its stderr is delivered to the model in the next turn so it can fix the regression.
The prompt
Add 'parking-permit' to the ServiceType union with Finnish label
'pysäköintilupa' in formatForCity. Update everything else that needs
updating, including any Record<ServiceType, ...> consumers, then run npm test.
The prompt is shaped to break things partway through. ServiceType is used in Record<ServiceType, number> mapped types; adding a fourth variant without updating those reducers produces type errors. Sequencing the edits will produce a brief intermediate state where types do not check.
What Claude did, in 23 tool calls
10 reads, 3 greps, 1 glob, 8 edits, 1 npm test. The final state:
| File | Change |
|---|---|
src/booking/types.ts | Add 'parking-permit' to the ServiceType union |
src/booking/service.ts | Add 'parking-permit': 15 to DEFAULT_DURATION |
src/notifications/templates.ts | Add 'parking-permit': 'pysäköintilupa' to SERVICE_LABEL |
src/reporting/service.ts | Add 'parking-permit' to SERVICES; add a pysäköintilupa line to formatForCity |
src/reporting/service.test.ts | Add 'parking-permit': 0 to the three byService toEqual assertions |
src/booking/service.test.ts | result[0].id → result[0]!.id |
The first five rows are the requested change. The sixth row is the article. The previous test landed in a prior session that did not run typecheck, so the project had carried an undiscovered noUncheckedIndexedAccess violation. The hook caught it.
The pre-existing typecheck error
Verified post-hoc by stashing all current changes and running npm run typecheck against the prior commit:
> tsc --noEmit
src/booking/service.test.ts(81,14): error TS2532: Object is possibly 'undefined'.
That is the exact line Claude fixed. The byService test from scenario #38 used expect(result[0].id).toBe(...). With noUncheckedIndexedAccess: true in tsconfig.json, indexed access returns T | undefined; the test compiled because of looser-than-strict defaults somewhere in the prior toolchain, but a strict typecheck flagged it. Claude added ! to assert the access was safe (the test asserts toHaveLength(1) immediately above, so the assertion is correct).
The hook surfaced the error, Claude addressed it, and the rest of the multi-file change still landed cleanly.
The debugging surface that surprised us
Here is where this article gets honest about a real finding. The events.jsonl from the captured run shows exactly one hook_started and one hook_response system event, both for the SessionStart hook. The PostToolUse hook, despite firing eight times (once per Edit, by configuration), does not appear in the stream as hook_response events.
$ jq -c 'select(.type == "system" and (.subtype == "hook_started" or .subtype == "hook_response"))' events.jsonl
{"subtype":"hook_started","hook_event":"SessionStart",...}
{"subtype":"hook_response","hook_event":"SessionStart","outcome":"success",...}
Two possible explanations, neither of them fully resolved:
-
The hook did fire, but
--output-format stream-jsondoes not emithook_responseevents for PostToolUse the way it does for SessionStart. PreToolUse blocks (article #37) had a similar gap: the denial surfaced as atool_resultwithis_error: true, not as ahook_responsesystem event. PostToolUse may have a similar wire form (perhaps the stderr is folded into the tool result silently) or no event at all. -
The hook did not fire, and Claude inferred the fix from
tsconfig.json. Claude’s read list includestsconfig.json. WithnoUncheckedIndexedAccess: truevisible there, Claude could have proactively used the non-null assertion without the hook running.
Both are consistent with the observed behavior. We can verify the first hypothesis only by running the same scenario with the hook deliberately broken (force exit 1 always) and seeing whether Claude reports failure messages, or by stripping the visible tsconfig flag and seeing whether Claude still produces the fix. Neither was done in this run.
The takeaway: a configured PostToolUse hook may not produce visible events in the way you expect. Verify it works by giving it a clear positive case (a deliberate type error) before relying on it as a contract.
Footguns
The events.jsonl signal is not symmetric across hook events. SessionStart fires hook_started + hook_response system events with outcome: success or outcome: failure. PreToolUse blocks surface as tool_result errors with the hook’s stderr in the content. PostToolUse, in this run, surfaced neither. Why this matters: do not write monitoring tooling that filters only on hook_response events. Different hook lifecycle phases use different wire formats, and at least one (PostToolUse) appears to be near-silent.
exit 2 is the documented feedback path; exit 0 is silent. The script in this article uses exit 0 when typecheck passes, which means most invocations produce no observable signal. That is fine for the agent (silence = OK), but means you cannot easily verify the hook ran by grepping events.jsonl. Why this matters: when debugging “did my hook fire?”, instrument the script to write a timestamp to a log file every time it runs. The events stream is not a reliable observability surface for this case.
npm run typecheck is slow. Each Edit triggers the hook, which runs the full project typecheck. On the demo (~12 source files) this takes ~2 seconds. On a real project of 500 files it could be 30+ seconds per edit, multiplied by 8 edits per session is a 4-minute tax. Why this matters: scope the typecheck to the files Claude edited, not the whole project. tsc --noEmit src/changed-file.ts plus its dependencies is much faster than full-project typecheck. Or use a watch-mode typechecker that you query incrementally.
Pre-existing errors get blamed on the current edit. The hook ran after the first Edit and surfaced the noUncheckedIndexedAccess error from a prior session. Claude correctly identified it as pre-existing and fixed it incidentally. A less-careful implementation might have rolled back the edit thinking it caused the error. Why this matters: if you operate strict-typing tools on a codebase that hasn’t been kept clean, the first PostToolUse hook firing will surface every accumulated error. Plan to do a clean-up pass before adopting the hook, or write the hook to compare error counts (only flag NEW errors).
Hook scripts run in series, not parallel. A PostToolUse hook that takes 5 seconds blocks the next tool call by 5 seconds. If you have multiple PostToolUse hooks (typecheck + format + lint), they run sequentially. Why this matters: keep PostToolUse work fast and side-effect-free. Heavy work (running tests, calling a remote service) belongs in CI, not in a per-edit hook.
exit 2 does not undo the edit. PostToolUse cannot undo what already happened; it can only complain. The model has to do the actual fix in the next turn. Why this matters: PostToolUse is a feedback loop, not a revert. If you need actual prevention, that is PreToolUse (article #37) or a permission deny rule.
When to use a PostToolUse typecheck hook
- Type-strict codebases. Every Edit gets verified; the hook makes it impossible to ship a session that broke types without Claude acknowledging it.
- Catching pre-existing regressions on the way through. This article’s case. The hook does not just catch this-edit errors; it surfaces every error the project has, which raises the floor.
- Auto-format after edit. Same shape: run
prettier --checkafter every Edit, surface any unformatted file as feedback so Claude reformats it. Cheap on small projects. - Auto-lint after edit.
eslint --quietafter every Edit, surface error-level findings. - Migration-aware checks. If you are partway through a refactor, a PostToolUse hook can verify that no edit introduces uses of the deprecated API.
When NOT to use this pattern
- Slow checks. Heavy unit-test runs do not belong here. The hook fires on every Edit, including cosmetic ones; that is a lot of overhead to add to a session.
- Checks that have unrelated noise. A linter that produces 200 warnings on legacy files will dominate the hook output and drown the signal you care about. Either silence the noise first or scope the check.
- External-service dependencies. A hook that calls a remote API (CI, code-review service) will fail when the service is down and break every session. Keep PostToolUse local.
- High-churn editing sessions. A session that touches 30 files in 30 minutes pays the hook cost 30 times. If the work is exploratory, run the check at the end with a
Stophook instead. - You need certainty the hook fired. As noted above, PostToolUse firings are not as visible in the events.jsonl stream as SessionStart’s. If verification matters for your workflow (compliance, audit), instrument the script with side-effect logging instead of relying on the events stream.