@nielspeter/ts-archunit
v0.7.2
Published
Architecture testing for TypeScript
Maintainers
Readme
ts-archunit
Architecture guardrails for AI coding agents. Executable rules that catch structural violations in CI — before they reach your codebase.
Inspired by Java's ArchUnit. Powered by ts-morph.
Documentation · Getting Started · What Can It Check?
The Problem
AI coding agents don't know your architecture. They generate code that compiles, passes type checks, and looks correct in isolation — but violates the structural decisions your team spent months establishing.
An agent will:
- Call
parseIntinstead of the sharedextractCount()helper - Throw
new Error()instead of your typedNotFoundError - Import the database driver directly from a service instead of going through the repository
- Copy-paste a parser function instead of using the shared utility
- Skip validation in a route handler
Code review catches some of this. But at scale — with multiple agents generating PRs across a large codebase — review becomes the bottleneck. You need automated enforcement.
The Solution
ts-archunit turns your architecture decisions into executable tests. They run in CI. Violations show up inline on the PR with clear messages explaining what's wrong, why it matters, and how to fix it — exactly the context an agent needs to self-correct.
classes(p)
.that()
.extend('BaseRepository')
.should()
.notContain(newExpr('Error'))
.rule({
id: 'repo/typed-errors',
because: 'Generic Error loses context and prevents consistent error handling',
suggestion: 'Use NotFoundError, ValidationError, or DomainError instead',
})
.check()When an agent violates this rule, it sees:
Architecture Violation [repo/typed-errors]
WebhookRepository.findById contains new 'Error' at line 42
at src/repositories/webhook.repository.ts:42
41 | if (!result) {
> 42 | throw new Error(`Webhook '${id}' not found`)
43 | }
Why: Generic Error loses context and prevents consistent error handling
Fix: Use NotFoundError, ValidationError, or DomainError insteadThe because and suggestion fields give the agent everything it needs to fix the violation without human intervention.
Why Not Just Import Rules?
Every other tool (dependency-cruiser, eslint-plugin-boundaries, ts-arch) only checks which files import which. That's necessary but insufficient.
AI agents don't violate architecture by importing wrong files. They violate it by writing the wrong code in the right place — inlining logic instead of delegating, using raw APIs instead of abstractions, skipping validation, throwing generic errors.
ts-archunit checks what happens inside your functions:
// "Services must delegate to repositories, not hardcode data"
functions(p)
.that()
.resideInFolder('**/services/**')
.should()
.satisfy(mustCall(/Repository/))
.check()
// "No eval anywhere in production code"
modules(p).that().resideInFolder('src/**').should().satisfy(moduleNoEval()).check()
// "Route handlers must validate input"
functions(p)
.that()
.resideInFolder('**/handlers/**')
.should()
.satisfy(mustCall(/validate|parse/))
.check()| Capability | ts-archunit | dependency-cruiser | eslint-plugin-boundaries | | -------------------------------------------------- | ----------- | ------------------ | ------------------------ | | Import path rules | Yes | Yes | Yes | | Body analysis (what's called inside functions) | Yes | No | No | | Type checking (string vs typed union) | Yes | No | No | | Cycle detection | Yes | Yes | No | | Baseline (gradual adoption) | Yes | No | No | | GitHub PR annotations | Yes | No | No |
Quick Start with Presets
One function call enforces an entire architecture pattern — layer ordering, cycles, import direction, package restrictions:
import { project } from '@nielspeter/ts-archunit'
import { layeredArchitecture } from '@nielspeter/ts-archunit/presets'
const p = project('tsconfig.json')
layeredArchitecture(p, {
layers: {
routes: 'src/routes/**',
services: 'src/services/**',
repositories: 'src/repositories/**',
},
shared: ['src/shared/**'],
strict: true,
})This generates 5 coordinated rules. Override individual rules without disabling the preset:
layeredArchitecture(p, {
layers: { ... },
overrides: {
'preset/layered/type-imports-only': 'off',
},
})Three presets available: layeredArchitecture, dataLayerIsolation, strictBoundaries.
Feed Your Architecture to the Agent
The explain command dumps all active rules as structured JSON — pipe it into your agent's system prompt so it knows the constraints before writing code:
npx ts-archunit explain arch.rules.ts{
"rules": [
{
"id": "repo/typed-errors",
"rule": "that extend 'BaseRepository' should not contain new 'Error'",
"because": "Generic Error loses context and prevents consistent error handling",
"suggestion": "Use NotFoundError, ValidationError, or DomainError instead"
}
]
}The agent reads the rules, understands the constraints, and generates compliant code from the start. When it doesn't, CI catches it with actionable violation messages.
Custom Rules
The fluent API reads like English:
// Select → Filter → Assert → Execute
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()Body Analysis
Inspect what happens inside functions — the differentiator:
// Ban inline parseInt — use the shared helper
classes(p)
.that()
.extend('BaseRepository')
.should()
.useInsteadOf(call('parseInt'), call('this.extractCount'))
.check()
// Services must delegate to repositories
functions(p)
.that()
.resideInFolder('**/services/**')
.should()
.satisfy(mustCall(/Repository/))
.check()
// No process.env in domain — use dependency injection
functions(p).that().resideInFolder('**/domain/**').should().satisfy(functionNoProcessEnv()).check()Layer Enforcement
slices(p)
.assignedFrom({
controllers: 'src/controllers/**',
services: 'src/services/**',
repositories: 'src/repositories/**',
})
.should()
.respectLayerOrder('controllers', 'services', 'repositories')
.check()
slices(p).matching('src/features/*/').should().beFreeOfCycles().check()Type-Level Rules
Check property types using the TypeScript type checker:
types(p)
.that()
.haveProperty('orderBy')
.should()
.havePropertyType('orderBy', not(isString()))
.rule({
because: 'Bare string orderBy is a SQL injection surface',
suggestion: "Use a union type: orderBy?: 'created_at' | 'updated_at'",
})
.check()Standard Rules Library
25+ ready-to-use rules across 8 categories:
import {
functionNoEval,
functionNoConsole,
functionNoJsonParse,
} from '@nielspeter/ts-archunit/rules/security'
import { functionNoGenericErrors } from '@nielspeter/ts-archunit/rules/errors'
import { mustCall } from '@nielspeter/ts-archunit/rules/architecture'
import { noDeadModules, noStubComments, noEmptyBodies } from '@nielspeter/ts-archunit/rules/hygiene'
functions(p).that().resideInFolder('src/**').should().satisfy(functionNoEval()).check()
functions(p).that().resideInFolder('src/**').should().satisfy(noEmptyBodies()).check()
functions(p).that().resideInFolder('src/**').should().satisfy(noStubComments()).check()Categories: rules/typescript, rules/security, rules/errors, rules/naming, rules/dependencies, rules/code-quality, rules/metrics, rules/architecture, rules/hygiene.
Baseline Mode
Adopt rules in existing codebases without fixing every pre-existing violation:
const baseline = withBaseline('arch-baseline.json')
// Only NEW violations fail — existing ones are recorded
classes(p).should().notContain(call('parseInt')).check({ baseline })GitHub Actions Annotations
Violations appear inline on PR diffs — automatically detected in GitHub Actions:
classes(p).should().notContain(call('eval')).check({ format: detectFormat() })Smell Detection
Find code drift — duplicate function bodies and inconsistent patterns:
smells.duplicateBodies(p).inFolder('src/routes/**').withMinSimilarity(0.9).warn()
smells
.inconsistentSiblings(p)
.inFolder('src/repositories/**')
.forPattern(call('this.extractCount'))
.warn()More Features
- Call matching — framework-agnostic route/handler inspection (Express, Fastify, Hono)
- Scoped rules —
within(routes).functions()for callback-level rules - Pattern templates — enforce return type shapes (
{ items, total, skip, limit }) - GraphQL rules — schema and resolver conventions
- Cross-layer validation — route/schema/SDK consistency
- Custom predicates and conditions —
definePredicate(),defineCondition(),and/or/notcombinators - Metrics — cyclomatic complexity, lines of code, method count limits
- CLI —
check,baseline,explain,--watchmode
Entry Points
| Function | Operates on | Use case |
| -------------- | ----------------------------------------- | ----------------------------------------------- |
| modules(p) | Source files | Import/dependency rules |
| classes(p) | Class declarations | Inheritance, decorators, methods, body analysis |
| functions(p) | Functions, arrow functions, class methods | Naming, parameters, body analysis |
| types(p) | Interfaces + type aliases | Property types, type safety |
| slices(p) | Groups of files | Cycles, layer ordering |
| calls(p) | Call expressions | Framework-agnostic route/handler matching |
| within(sel) | Scoped callbacks | Rules inside matched call callbacks |
Compared to Other Tools
| Capability | ts-archunit | dependency-cruiser | ArchUnitTS |
| ------------------------------------------------- | ----------- | -------------------------------------------------------------------- | -------------------------------------------------------- |
| Import path rules | Yes | Yes | Yes |
| Body analysis (calls, access, constructors) | Yes | No | No |
| Type checking (resolved types via ts-morph) | Yes | No | No |
| Class rules (inheritance, decorators, members) | Yes | No | No |
| Function rules (params, return types, async) | Yes | No | No |
| Cycle detection | Yes | Yes | Yes |
| Parameterized presets | Yes | Flat config | No |
| Baseline / gradual adoption | Yes | No | No |
| GitHub PR annotations | Yes | No | No |
| Violation messages with fix suggestions | Yes | No | No |
| explain command (dump rules as JSON for agents) | Yes | No | No |
| OO metrics (LCOM, coupling, instability) | No | No | Yes |
| PlantUML diagram compliance | No | No | Yes |
| Dependency graph visualization | No | Yes (dot, HTML) | No |
| License checking | No | Yes | No |
| Nx monorepo support | No | No | Yes |
Use ts-archunit when you need to enforce what happens inside functions — call patterns, error types, missing delegation, stub comments — and when AI agents are generating code that needs architectural guardrails. This is the only tool that catches "service calls parseInt instead of extractCount()".
Use dependency-cruiser when you only need import direction rules and want fast graph visualization, license compliance checking, or stability metrics. It's faster (no ts-morph project load) and has mature HTML/dot reporting.
Use ArchUnitTS when you need OO metrics (LCOM cohesion, coupling factor, distance from main sequence), PlantUML diagram validation, or Nx monorepo project-graph awareness.
Use ts-archunit + dependency-cruiser together if you want both body-level enforcement and dependency graph visualization.
Install
npm install -D @nielspeter/ts-archunitRequires Node.js >= 24 and a tsconfig.json. Works with vitest (recommended) or jest.
License
MIT
