●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work
The Types Landed, but Nothing Got Safer — Where I Delegated a JS→TS Migration to Claude Code, and Where I Didn't
A green tsc run does not mean any is gone. Measuring a migration by type coverage, drawing a clear line between what Claude Code handles well and what a human must decide, and a CI ratchet that refuses regressions.
A week after tsc --noEmit went green, production threw Cannot read properties of undefined.
The migration itself had gone smoothly. I converted the utility layer that was still JavaScript, turned on strict: true, and watched CI pass. As far as I was concerned, it was finished.
Tracing the crash led to a function that receives an external API response. The type was there. It just wasn't a type I had written, or one Claude Code had inferred — it was as ApiResponse, a lie I had pushed in by hand.
Having types and being safe turned out to be different things. As an indie developer running four sites on my own, that one stung.
Four ways any leaks past a green tsc
Auditing the codebase afterward taught me that any doesn't disappear when you remove it. It changes shape. In my project the leaks fell into four paths.
Leak path
Does tsc pass?
What actually happened
as assertions
Yes
An external response was forced into as ApiResponse; a missing field flowed downstream as undefined
Dependencies without type definitions
Yes (even under noImplicitAny, imports slip through)
A small library with no @types returned an effective any that propagated
Defaulted generics
Yes
Omitted type arguments on useState() and my own helpers let inference widen
Values in catch clauses
Yes
Branches touched error.message without ever narrowing from unknown
All four share a trait: nothing errors, so nothing tells you. As long as you measure progress in converted files, these stay invisible until production finds them for you.
Swap the progress metric from file count to type coverage
So I changed the metric to the share of expressions that actually carry a type — type coverage. type-coverage reports it in one command.
With --detail, every expression still typed as any comes back with a line number. That list was the real state of my migration. File-wise I was at 100% converted. Expression-wise I was at 94.5% — roughly 830 spots spread across those four leak paths.
The number matters less than the breakdown. Group the leaks by file and the work order writes itself.
In my case the top five files held about 60% of the leaks. That single fact decided the plan: redesigning the boundaries in those five files would beat spreading thin fixes across the whole tree. The migration hadn't failed as a surface. It had failed at specific points.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A measurement script that tracks migration progress by type coverage instead of converted file count, and how to read its output
✦A concrete boundary table: work Claude Code finishes faster than you can, versus decisions that break quietly when delegated
✦A CI ratchet that rejects any-ratio regressions per PR, plus how to record deliberate exceptions
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Now the line itself. Three kinds of work were clearly faster in Claude Code's hands.
Mechanical syntax replacement.module.exports to export, resolving require, lifting JSDoc into annotations. Almost no judgment involved, and reviewing means skimming a diff. I handed these over a directory at a time rather than file by file.
Types that are inferable from the implementation. Pure internal functions, constant objects, reducer return values. When reading the code pins the type down to exactly one answer, delegation was both faster and more accurate than my own typing.
Classifying the error wall. Paste the output of tsc --noEmit and ask for a grouping, and 300 lines collapse into eight root causes. This was the single most useful pattern for me. The hour normally spent staring at an error dump simply vanishes.
# Group errors by cause before touching anythingnpx tsc --noEmit 2>&1 | tee /tmp/tsc-errors.txtclaude -p "$(cat /tmp/tsc-errors.txt)Group these errors by root cause and order the groups by number of affected files.For each group, judge whether the fix is purely a type change or requires animplementation change. Do not write code yet." \ --allowed-tools "Read,Grep" \ --output-format text
Restricting --allowed-tools to reads keeps the investigation phase honest. If edits start while you're still classifying, the evidence moves underneath you. Separating survey from execution means you can walk back.
What I refused to delegate
Four decisions broke quietly when I handed them over. They are the origin story of that first crash.
Types at external boundaries. API responses, environment variables, file reads, user input. The type system cannot know what arrives at runtime, and as is where the lie enters. Claude Code will happily write the assertion — but only because I told it a lie was acceptable.
Deciding where any is allowed. "Just here is fine" weighs cost against risk. I kept that on the human side.
How to wrap untyped dependencies. Pass it through, write a thin wrapper, or drop the dependency entirely? That decision concerns the lifespan of the project, and it rarely matches the shortest path to silencing today's error.
Who suffers when it breaks. Anywhere a swallowed type error shows up as a billing amount, trading speed for care is worth it.
For boundaries, I replaced assertion with verification.
// src/lib/api-client.tsimport { z } from "zod";const ApiResponseSchema = z.object({ id: z.string(), title: z.string(), publishedAt: z.string().datetime(), tags: z.array(z.string()).default([]),});export type ApiResponse = z.infer<typeof ApiResponseSchema>;export async function fetchArticle(id: string): Promise<ApiResponse> { const res = await fetch(`https://example.com/api/articles/${id}`); if (!res.ok) { throw new Error(`fetchArticle failed: ${res.status}`); } // parse, not as. The type is the *result* of a check, not a declaration. return ApiResponseSchema.parse(await res.json());}
as ApiResponse says "let's pretend." parse says "stop if it isn't so." The typing effort is nearly identical; the failure mode is not. I had about 20 boundaries, and converting them took half a day. No crash of that shape has returned since.
I also like deriving the type from z.infer. Schema and type stay single-sourced, so there's no chance of fixing one and quietly diverging from the other.
Put a ratchet in CI so it can't slide back
A line you decide but don't enforce erodes. Mine did — mid-migration I let one as through because I was in a hurry.
So I made regression itself the thing CI blocks. Not "demand 100%," but "refuse to go down."
#!/usr/bin/env bash# scripts/type-coverage-gate.sh — reject type-coverage regressions per PRset -euo pipefailBASELINE_FILE=".type-coverage-baseline"TOLERANCE="0.10" # percentage points of noise to absorbCURRENT=$(npx type-coverage --strict 2>/dev/null \ | grep -oE '[0-9]+\.[0-9]+%' | tr -d '%')if [ ! -f "$BASELINE_FILE" ]; then echo "$CURRENT" > "$BASELINE_FILE" echo "baseline initialized at ${CURRENT}%" exit 0fiBASELINE=$(cat "$BASELINE_FILE")# Regression check (tolerance allowed)if awk -v c="$CURRENT" -v b="$BASELINE" -v t="$TOLERANCE" \ 'BEGIN { exit !(c < b - t) }'; then echo "Type coverage regressed: ${BASELINE}% -> ${CURRENT}%" echo "New any locations:" npx type-coverage --detail --strict 2>/dev/null | grep -E ':[0-9]+:[0-9]+' | head -20 echo "" echo "If this is intentional, update .type-coverage-baseline with a reason." exit 1fi# Ratchet: lock in any improvementif awk -v c="$CURRENT" -v b="$BASELINE" 'BEGIN { exit !(c > b) }'; then echo "$CURRENT" > "$BASELINE_FILE" echo "baseline raised: ${BASELINE}% -> ${CURRENT}%"fiecho "Type coverage: ${CURRENT}% (baseline ${BASELINE}%)"
The ratchet at the end is the whole point. Every gain locks itself in, so no one maintains a target number by hand, and the quiet weeks where migration stalls still hold the line.
On GitHub Actions:
# .github/workflows/type-coverage.ymlname: type-coverageon: [pull_request]jobs: gate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" - run: npm ci - run: bash scripts/type-coverage-gate.sh # Commit the baseline when it moves, so the change leaves a trace - name: commit baseline if: success() run: | if ! git diff --quiet .type-coverage-baseline; then git config user.name "github-actions" git config user.email "actions@github.com" git add .type-coverage-baseline git commit -m "chore: raise type-coverage baseline" git push fi
When I do grant an exception, the reason goes in the commit message that lowers the baseline. Six months later, git log explains it to me. A gate isn't a wall built to stop you — it's a device for recording why you went through.
Split the work along boundaries, not directories
Early on I asked for whole directories: "convert this to TypeScript." Some days it worked. Other days a pile of as came back. The results wouldn't settle.
The reason was plain enough. I was slicing by the implementation's convenience — the directory — instead of the type system's — the boundary.
Human writes the schema first; delegate only the implementation
Read each site individually
Anything touching money or billing
Human writes it; Claude Code reviews
Verify alongside tests
Once a human fixes the boundary schema up front, the delegated side has nowhere to put a lie. The type is already pinned upstream, so downstream can only infer. Placing the constraint first finished faster than granting freedom.
This generalizes beyond migrations. If you run work in parallel with worktrees, aligning the split with boundaries makes the eventual merge far less painful.
What was left three months later
Type coverage went from 94.5% to 99.2%. The remaining 0.8% lives inside wrappers around untyped dependencies, each marked with // type-coverage:ignore-next-line and a one-line reason.
The number matters less than the gate quietly running. On a rushed day, reaching for as stops CI, and I get one forced pause. The judgment no longer has to be made every time — it moved into the machinery.
My original mistake wasn't delegating too much to Claude Code. It was that I couldn't put into words what I was delegating. Move fast with a fuzzy boundary and you break fast.
If you're starting today, run npx type-coverage --detail --strict once. The codebases most confident they've finished migrating are the ones with the longest output. That number is the first material you have for redrawing the line.
Thanks for reading — I hope some of this proves useful in your own migration.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.