Moon :typecheck too slow? It's (mostly) a caching configuration problem

moonrepotypescriptperformancemonorepo

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 inputs and outputs → 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 check doing 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:

  1. Run 1 produces a new .tsbuildinfo
  2. Run 2 hashes inputs, sees .tsbuildinfo changed, and re-runs again
  3. 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 :typecheck time 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: true behavior (no caching, no CI)
  • If validate is 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 --noEmit with outputs: [".tsbuildinfo"] won't work unless tsconfig.json has "incremental": true
  • deno check produces 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 .tsbuildinfo from inputs anywhere it’s also an output.
  • Fix 2: make Node typecheck tasks cacheable
    • Ensure the project actually produces .tsbuildinfo (enable incremental via tsconfig.json or CLI flags).
    • Declare outputs: [".tsbuildinfo"] for the Moon task.
  • 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-affected alias or update docs to moon run :typecheck --affected.

A quick checklist for each typecheck task

  • Does it have correct inputs?
    • Include tsconfig.json / deno.json / relevant source globs.
  • Does it declare outputs?
    • Node: .tsbuildinfo (if incremental)
    • Deno: a stamp file (if you want caching)
  • Does it accidentally list outputs as inputs?
    • Especially .tsbuildinfo
  • 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:

  1. Is .tsbuildinfo still in inputs?
  2. Does the underlying tool actually produce .tsbuildinfo?
  3. 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 --affected for your inner loop

Do those, and repeat :typecheck runs become dramatically faster.