Moonrepo TypeScript Monorepo Testing Setup: From Zero to CI
This guide walks through the complete setup of a TypeScript monorepo using Moon.build from scratch — pnpm workspaces, shared tsconfig, Vitest per package, Moon task orchestration, and a GitHub Actions CI pipeline with remote caching. Estimated setup time: 45 minutes.
Key Takeaways
- Moon requires explicit project registration and task definitions, which makes the entire workspace self-documenting.
- TypeScript project references enforce correct build order and enable incremental compilation across packages.
- A shared tsconfig.base.json propagated through Moon's toolchain eliminates type-check inconsistencies.
- pnpm workspaces and Moon's project graph work independently — Moon reads package.json workspaces but adds its own dependency model on top.
- The first CI run is always slow; after that, Moon's cache makes subsequent runs 70-90% faster on average.
Prerequisites
Before starting, ensure you have:
- Node.js 20 or later
- pnpm 8 or later (
npm install -g pnpm) - Git initialized in your project root
- Moon CLI installed (
curl -fsSL https://moonrepo.dev/install/moon.sh | bash)
Step 1: Initialize the pnpm Workspace
Create the project root and initialize pnpm:
mkdir my-monorepo && <span class="hljs-built_in">cd my-monorepo
git init
pnpm initCreate the pnpm workspace configuration:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'Create the directory structure:
mkdir -p packages/utils packages/ui apps/web apps/apiCreate a root .gitignore:
node_modules
dist
.moon/cache
.moon/out
coverage
test-results
*.logStep 2: Initialize Moon
Run Moon's initialization command:
moon initAnswer the prompts:
- VCS:
git - Package manager:
pnpm - Primary language:
typescript
This creates .moon/workspace.yml. Update it:
# .moon/workspace.yml
vcs:
manager: git
defaultBranch: main
remoteCandidates:
- origin
projects:
- 'packages/*'
- 'apps/*'
runner:
cacheLifetime: '7 days'
inheritColorsForPipedTasks: true
logRunningCommand: true
node:
version: '20.11.0'
packageManager: pnpm
packageManagerVersion: '8.15.0'Create the toolchain configuration:
# .moon/toolchain.yml
node:
version: '20.11.0'
packageManager: pnpm
packageManagerVersion: '8.15.0'
addEnginesConstraint: true
dedupeOnLockfileChange: true
typescript:
createMissingConfig: true
rootConfigFileName: tsconfig.json
rootOptionsConfigFileName: tsconfig.options.json
syncProjectReferences: true
syncProjectReferencesToPaths: trueThe syncProjectReferences: true setting tells Moon to automatically maintain TypeScript project references as your dependency graph evolves — you won't need to manually update tsconfig.json files when adding a new dependsOn.
Step 3: Create Shared TypeScript Configuration
Create the root TypeScript options config:
// tsconfig.options.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}Create the root project reference config:
// tsconfig.json
{
"files": [],
"references": []
}Moon will populate the references array automatically as you add dependsOn to project moon.yml files (because of syncProjectReferences: true).
Step 4: Define Shared Task Defaults
Create the inherited task configuration:
# .moon/tasks.yml
tasks:
build:
command: tsc --build tsconfig.json
inputs:
- 'src/**/*'
- 'tsconfig.json'
- '/tsconfig.options.json'
outputs:
- 'dist'
- '.tsbuildinfo'
options:
cache: true
runInCI: true
typecheck:
command: tsc --noEmit
inputs:
- 'src/**/*'
- 'tsconfig.json'
- '/tsconfig.options.json'
options:
cache: true
runInCI: true
test:
command: vitest run
inputs:
- 'src/**/*'
- 'tests/**/*'
- 'vitest.config.ts'
- 'vitest.config.mts'
- 'package.json'
- '/vitest.shared.ts'
outputs:
- 'coverage'
- 'test-results'
deps:
- 'typecheck'
options:
cache: true
runInCI: true
outputStyle: stream
lint:
command: eslint src --ext .ts,.tsx --max-warnings 0
inputs:
- 'src/**/*'
- '.eslintrc.*'
- '/.eslintrc.*'
options:
cache: true
runInCI: true
test:watch:
command: vitest watch
local: true
inputs:
- 'src/**/*'
- 'tests/**/*'
options:
cache: false
persistent: trueStep 5: Create the utils Package
mkdir -p packages/utils/src packages/utils/tests// packages/utils/package.json
{
"name": "@myorg/utils",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"test": "vitest run"
}
}// packages/utils/tsconfig.json
{
"extends": "../../tsconfig.options.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": []
}# packages/utils/moon.yml
language: typescript
type: library
# Inherits all tasks from .moon/tasks.ymlCreate source and test files:
// packages/utils/src/format.ts
export function formatDate(date: Date, locale = 'en-US'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function formatCurrency(
amount: number,
currency = 'USD',
locale = 'en-US'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount)
}// packages/utils/src/index.ts
export * from './format.js'// packages/utils/tests/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, formatCurrency } from '../src/format.js'
describe('formatDate', () => {
it('formats a date in the default locale', () => {
const date = new Date('2024-01-15')
const result = formatDate(date, 'en-US')
expect(result).toBe('January 15, 2024')
})
it('formats a date in a different locale', () => {
const date = new Date('2024-01-15')
const result = formatDate(date, 'de-DE')
expect(result).toContain('15')
expect(result).toContain('2024')
})
})
describe('formatCurrency', () => {
it('formats USD by default', () => {
const result = formatCurrency(1234.56)
expect(result).toBe('$1,234.56')
})
it('formats EUR', () => {
const result = formatCurrency(1234.56, 'EUR', 'de-DE')
expect(result).toContain('1.234,56')
expect(result).toContain('€')
})
})// packages/utils/vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'utils',
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json'],
reportsDirectory: 'coverage',
},
reporters: [
'default',
['junit', { outputFile: 'test-results/junit.xml' }],
],
},
})Step 6: Create the api-client Package (Depends on utils)
mkdir -p packages/api-client/src packages/api-client/tests// packages/api-client/package.json
{
"name": "@myorg/api-client",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"@myorg/utils": "workspace:*"
}
}# packages/api-client/moon.yml
language: typescript
type: library
dependsOn:
- utils # Moon will add tsconfig reference automatically
# Inherits all tasks from .moon/tasks.yml// packages/api-client/src/client.ts
import { formatCurrency } from '@myorg/utils'
export interface Product {
id: string
name: string
priceInCents: number
currency: string
}
export interface FormattedProduct extends Product {
formattedPrice: string
}
export function formatProduct(product: Product): FormattedProduct {
return {
...product,
formattedPrice: formatCurrency(product.priceInCents / 100, product.currency),
}
}// packages/api-client/tests/client.test.ts
import { describe, it, expect } from 'vitest'
import { formatProduct } from '../src/client.js'
describe('formatProduct', () => {
it('formats the product price', () => {
const result = formatProduct({
id: '1',
name: 'Test Product',
priceInCents: 999,
currency: 'USD',
})
expect(result.formattedPrice).toBe('$9.99')
})
})Step 7: Install Dependencies
# Root tooling
pnpm add -D -w vitest @vitest/coverage-v8 typescript eslint
<span class="hljs-comment"># Install package dependencies
pnpm installStep 8: Set Up GitHub Actions CI
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
name: Lint, Typecheck, Test
runs-on: ubuntu-latest
env:
MOONBASE_SECRET_KEY: ${{ secrets.MOONBASE_SECRET_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # required for Moon's affected detection
- name: Setup Moon
uses: moonrepo/setup-moon-action@v1
with:
auto-install: true
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run affected tasks
run: |
moon run :lint :typecheck :test \
--affected \
--base ${{ github.base_ref || 'main' }} \
--head ${{ github.sha }} \
--concurrency 4
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/test-results/junit.xml'
if-no-files-found: ignore
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: '**/coverage/'
if-no-files-found: ignoreStep 9: Verify the Setup
Run your first Moon task:
# Run tests for all packages
moon run :<span class="hljs-built_in">test
<span class="hljs-comment"># Verify caching works — second run should be instant
moon run :<span class="hljs-built_in">test
<span class="hljs-comment"># Run only affected packages (simulates CI behavior)
moon run :<span class="hljs-built_in">test --affected --base mainExpected output on the second run:
▸ utils:typecheck (cache hit, skipped in 3ms)
▸ utils:test (cache hit, skipped in 2ms)
▸ api-client:typecheck (cache hit, skipped in 2ms)
▸ api-client:test (cache hit, skipped in 3ms)
Tasks: 4 cached, 0 failed, 0 passed
Time: 127msStep 10: Add a New Package (Testing the Workflow)
The real test of your setup is how it handles growth. Add a new package:
mkdir -p packages/validators/src packages/validators/testsCreate packages/validators/moon.yml:
language: typescript
type: library
dependsOn:
- utilsCreate packages/validators/package.json, tsconfig.json, and vitest.config.ts following the same pattern as utils. Moon will:
- Detect the new project from the
packages/*glob inworkspace.yml - Automatically add a TypeScript project reference to
packages/utils/tsconfig.json - Include
validatorsin the dependency graph for affected detection
Run moon sync to apply the TypeScript reference sync:
moon sync projectsCommon Issues and Fixes
Error: "Project not found" Run moon query projects to see what Moon has detected. If your new package is missing, check that its directory is covered by the glob in workspace.yml and that it has a package.json.
TypeScript errors in CI but not locally Check that your tsconfig.options.json target and module settings match the Node.js version in toolchain.yml. A mismatch causes different module resolution behavior.
Cache miss on every CI run The most common cause is a file in your inputs glob that changes between runs — a lockfile, a generated file, or a timestamp. Run moon query hash <project>:<task> on your local machine and in CI and compare the hashes. The differing input will be visible in the hash computation output.
Tests pass locally but fail in CI Check whether your tests depend on environment variables or file paths that differ between environments. Add any environment-specific inputs to the env section of your task definition so they're included in the cache key.
Adding Browser Test Coverage
Your Moon CI pipeline now handles linting, type-checking, and unit tests. For browser-level end-to-end coverage of your deployed applications, HelpMeTest integrates into the same CI workflow. Write tests in plain English — describe the user action and the expected result — and HelpMeTest runs them against your deployed app. No Playwright configuration, no test runner setup. It's the natural complement to a Moon + Vitest unit test stack when you need to verify the full user experience.
Summary
Setting up a TypeScript monorepo with Moon takes roughly 45 minutes and pays dividends on every subsequent PR. The key pieces are: pnpm workspaces for package management, Moon's syncProjectReferences for automatic TypeScript configuration maintenance, shared task defaults in .moon/tasks.yml to keep per-package config minimal, and --affected detection in GitHub Actions to run only what changed. Once the first slow CI run warms the cache, your team will see dramatically faster feedback on every pull request.