Moon :typecheck too slow? It's (mostly) a caching configuration problem
Moon :typecheck too slow? It's (mostly) a caching configuration problem
If moon run validate && moon run :typecheck feels "far too long", the first instinct is to blame TypeScript or Deno. In this repo, the bigger culprit is simpler:
Moon can only skip work if tasks declare correct inputs and outputs.
Right now, most typecheck tasks either:
- have no outputs → Moon must invoke them every run
- are cache-busted by listing the same file in both
inputsandoutputs→ Moon always sees them as changed
This article explains how to audit and fix that, without weakening your validation guarantees.
The audit: how bad is it?
In this repo, we found 33 projects with typecheck tasks.
Before fixes, the breakdown was:
| Category | Count | Result | |----------|-------|--------| | Cache-busted (output in inputs) | 2 | Always re-runs | | No outputs declared | 31 | Always re-runs | | Properly cacheable | 0 | — |
So 100% of typecheck tasks would always re-run, which explains why :typecheck takes 30-60s even on repeat runs with no changes.
After Phase 1 (removing .tsbuildinfo from inputs in the two incremental Node apps):
| Category | Count | Result | |----------|-------|--------| | Properly cacheable | 2 | Can skip when unchanged | | No outputs declared | 31 | Still always re-runs |
After Phase 2 (enabling incremental + outputs for remaining Node projects):
| Category | Count | Result | |----------|-------|--------| | Properly cacheable | 6 | Can skip when unchanged | | No outputs (Deno) | 27 | Still always re-runs |
The 6 cacheable Node projects are: tier1-nextjs, trigger, ui-nextjs, mobile-expo, content-library, analysis-definitions.
The two kinds of “slow”
There are two different “slow” experiences:
- Slow TypeScript/Deno itself (
tsc/deno checkdoing lots of actual analysis) - Slow orchestration (Moon repeatedly re-running tasks that could be skipped)
In this repo, tsc is already configured to be incremental in the heaviest Node projects (Next.js + Trigger), so the surprising part is:
You can still be slow even when tsc is fast, if Moon can’t skip the task invocation.
What Moon needs to cache/skip typecheck
Moon needs two things per task:
- inputs: what files determine whether the task must re-run
- outputs: what files the task produces, so Moon can cache/restore them (and decide “nothing changed”)
If a typecheck task has no outputs, Moon has nothing to cache and will run it again next time.
The cache-buster bug: .tsbuildinfo in both inputs and outputs
For incremental tsc, the build-info file (.tsbuildinfo) is an output artifact that changes as the checker runs.
If you list it as an input too, you guarantee a cache miss:
- Run 1 produces a new
.tsbuildinfo - Run 2 hashes inputs, sees
.tsbuildinfochanged, and re-runs again - Repeat forever
Fix: .tsbuildinfo should be an output only, not an input.
Why “no outputs” makes :typecheck always re-run
A large chunk of projects define:
typecheck:
command: ...
inputs: [...]
# no outputs
This is “correct” in the sense that typechecking doesn’t naturally emit build artifacts (especially for deno check), but it means:
- Moon can’t cache the result
- Moon will always invoke the task again
- Your full
moon run :typechecktime doesn’t converge on repeat runs
Deno deno check: fast, but still expensive at scale
deno check often feels fast per-package, but :typecheck runs a lot of packages. If none are cacheable, you pay the startup + resolution cost every time.
The standard workaround is a stamp file:
- run the check
- if it succeeds, write a file like
.moon/typecheck.stamp - declare that stamp as the task output
That gives Moon an output to cache and a stable signal that “this task ran successfully for these inputs”.
Whether this is worth it depends on how much Deno contributes to your wall-clock :typecheck.
“Affected” runs: the fastest safe workflow change
There’s an easy workflow win that doesn’t require any task refactors:
- Use affected typechecking for the inner loop
moon run :typecheck --affected
This runs typecheck only for changed projects (and dependents). It often turns 30–60s into single digits when you’re iterating on a small surface area.
If your docs mention moon run typecheck-affected but it doesn’t exist, that’s a documentation mismatch: either create the alias task or update docs to the direct invocation.
Validation (moon run validate) isn't the main problem
In this repo, validate is intentionally quick and intentionally "local only":
- It's already parallelized
- It's documented at ~1–2 seconds
- CI runs specific validators directly (not the wrapper)
So optimize validate only if you see it regressing; focus on :typecheck.
Common misconceptions
"Shell fan-out is slower than Moon deps"
Some advice suggests replacing shell-parallel task invocation with Moon's deps graph. In practice:
- Moon startup overhead is small
- The real question is whether you want the
local: truebehavior (no caching, no CI) - If
validateis already 1-2s, optimizing its structure has diminishing returns
"Just add outputs to make it cacheable"
This only works if the underlying tool actually produces the output. For example:
tsc --noEmitwithoutputs: [".tsbuildinfo"]won't work unlesstsconfig.jsonhas"incremental": truedeno checkproduces nothing by default—you need a stamp file pattern
What local: true actually does
According to Moon's documentation, local: true means:
"caching is turned off, the task will not run in CI, terminal output is not captured, and the task is marked as persistent"
So local: true does disable caching. This is intentional for dev-only tasks, but means those tasks will always re-run.
Recommended fix strategy (ordered by ROI)
- Fix 1: stop cache-busting incremental
tsc(highest ROI, lowest risk)- Remove
.tsbuildinfofrominputsanywhere it’s also anoutput.
- Remove
- Fix 2: make Node
typechecktasks cacheable- Ensure the project actually produces
.tsbuildinfo(enable incremental viatsconfig.jsonor CLI flags). - Declare
outputs: [".tsbuildinfo"]for the Moon task.
- Ensure the project actually produces
- Fix 3: address “no inputs/no outputs” tasks
- If a typecheck task has no inputs, Moon can’t accurately decide when to rerun; add a correct input set.
- Fix 4 (optional): add Deno stamp outputs where it matters
- Only if Deno contributes meaningfully to total time.
- Fix 5: standardize “affected” workflows
- Add a
typecheck-affectedalias or update docs tomoon run :typecheck --affected.
- Add a
A quick checklist for each typecheck task
- Does it have correct
inputs?- Include
tsconfig.json/deno.json/ relevant source globs.
- Include
- Does it declare
outputs?- Node:
.tsbuildinfo(if incremental) - Deno: a stamp file (if you want caching)
- Node:
- Does it accidentally list outputs as inputs?
- Especially
.tsbuildinfo
- Especially
- Does the underlying tool actually produce the output?
- If not, the task will never become cacheable.
Concrete example: fixing the cache-buster
Before (cache-busted):
typecheck:
command: "pnpm"
args: ["run", "typecheck"]
inputs:
- "app/**/*"
- "tsconfig.json"
- ".tsbuildinfo" # ← BUG: output listed as input
outputs:
- ".tsbuildinfo"
After (properly cacheable):
typecheck:
command: "pnpm"
args: ["run", "typecheck"]
inputs:
- "app/**/*"
- "tsconfig.json"
# .tsbuildinfo removed from inputs
outputs:
- ".tsbuildinfo"
Prerequisite: The project's tsconfig.json must have:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
Without "incremental": true, tsc won't produce .tsbuildinfo and the output declaration is meaningless.
Enabling incremental for a project that doesn't have it
If a Node project's typecheck task has no outputs AND its tsconfig doesn't enable incremental, you need to fix both:
Step 1: Update tsconfig.json
{
"compilerOptions": {
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
Step 2: Update moon.yml
typecheck:
command: "pnpm"
args: ["run", "typecheck"]
inputs:
- "src/**/*.{ts,tsx}"
- "tsconfig.json"
outputs:
- ".tsbuildinfo"
Step 3: Add .tsbuildinfo to .gitignore (optional but recommended)
.tsbuildinfo
This pattern was applied to ui-nextjs, mobile-expo, content-library, and analysis-definitions in this repo.
How to verify your fix worked
After making changes, run typecheck twice with no code changes between:
# First run (populates cache)
moon run :typecheck
# Second run (should skip cached tasks)
moon run :typecheck
Look for output like:
▶ project:typecheck (cached, 0ms)
If you see (cached) for previously slow tasks, the fix worked. If tasks still show execution time, check:
- Is
.tsbuildinfostill in inputs? - Does the underlying tool actually produce
.tsbuildinfo? - Is the task marked
local: true?
How to audit all typecheck tasks
Find all projects with typecheck tasks:
grep -rl "typecheck:" --include="moon.yml" .
Check which have outputs defined:
moon query tasks --json | jq '.tasks | to_entries[] |
select(.value.typecheck?) |
{project: .key, hasOutputs: (.value.typecheck.outputs != null)}'
This reveals which tasks need attention.
What we actually changed in this repo
Phase 1: Fix cache-busters (2 projects)
| Project | Change |
|---------|--------|
| apps/tier1-nextjs | Removed .tsbuildinfo from moon.yml inputs |
| services/background/trigger | Removed .tsbuildinfo from moon.yml inputs |
| Root moon.yml | Added missing typecheck-affected task |
Phase 2: Enable incremental (4 projects)
| Project | tsconfig.json | moon.yml |
|---------|---------------|----------|
| packages/ui-nextjs | Added incremental + tsBuildInfoFile | Added outputs |
| apps/mobile-expo | Added incremental + tsBuildInfoFile | Added outputs |
| packages/content-library | Added incremental + tsBuildInfoFile | Added outputs |
| packages/analysis-definitions | Added incremental + tsBuildInfoFile | Added inputs + outputs |
Result: 6 of 33 typecheck tasks are now cacheable. The remaining 27 are Deno projects that would require stamp files.
Bottom line
When Moon :typecheck is "too slow", the fix is rarely "make TypeScript faster". More often it's:
- stop invalidating caches
- declare outputs so Moon can skip work
- use
--affectedfor your inner loop
Do those, and repeat :typecheck runs become dramatically faster.