Appearance
ts-archunit
Architecture testing for TypeScript. Enforce structural rules across your codebase as executable tests that run in CI.
Inspired by Java's ArchUnit. Powered by ts-morph.
Get Started → · What Can It Check? · GitHub
What ts-archunit Can Enforce
Layer & dependency rules
- Domain must not import from infrastructure
- Controllers must go through services, not call repositories directly
- Shared packages must not depend on app code
- No circular dependencies between feature modules
Code patterns
- Repositories must use
this.extractCount(), not inlineparseInt - Services must throw typed errors (
NotFoundError), not genericError - Route handlers must call
normalizePagination(), not manualNumber() - SDK wrappers must use
buildQueryString(), not rawURLSearchParams - No
eval(), noconsole.login production code
Naming & structure
- Controllers end with
Controller, services end withService - Repositories must extend
BaseRepository - All services must be exported
- DTOs live in the
dto/folder, not scattered around
Type safety
orderByfields must be typed unions, not barestring- No
anytypes on class properties - No
astype assertions in method bodies - No
!non-null assertions
API consistency
- All list endpoints return
{ items, total, skip, limit } - No copy-pasted
parseXxxOrder()functions across routes - Every route has schema validation
- GraphQL collection types have standard pagination fields
Architecture boundaries
- Layer ordering: controllers → services → repositories → domain
- Feature modules are cycle-free
- Routes ↔ schemas ↔ SDK types stay in sync
Does This Sound Familiar?
"The domain layer imports from the database layer."
Someone added a quick import to get a type. Then another. Now your clean architecture has 15 backdoors.
typescript
// ts-archunit catches this on the PR that introduces it:
modules(p)
.that()
.resideInFolder('**/domain/**')
.should()
.notImportFromCondition('**/repositories/**')
.check()"Half the repositories throw new Error() instead of typed errors."
A shared NotFoundError exists. But 12 out of 40 repositories still throw generic Error. Code review didn't catch it because each PR only touched one file.
typescript
classes(p)
.that()
.extend('BaseRepository')
.should()
.notContain(newExpr('Error'))
.because('use NotFoundError, ValidationError, etc.')
.check()"Feature modules depend on each other in circles."
The auth module imports from billing, billing imports from notifications, notifications imports from auth. Nobody planned this. It just happened.
typescript
slices(p).matching('src/features/*/').should().beFreeOfCycles().check()ts-archunit turns these rules into tests. They run in CI. Violations are caught on the PR that introduces them — not 18 months later during a manual audit.
What Other Tools Can't Do
Every existing tool checks which files import which. That's useful, but it misses the real problems.
ts-archunit checks what happens inside your functions:
typescript
// "Repositories must use the shared helper, not inline parseInt"
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()typescript
// "Query options must use typed unions, not bare string"
types(p)
.that()
.haveProperty('orderBy')
.should()
.havePropertyType('orderBy', not(isString()))
.check()typescript
// "No circular dependencies between feature modules"
slices(p).matching('src/features/*/').should().beFreeOfCycles().check()| Capability | ts-archunit | dependency-cruiser | eslint-plugin-boundaries |
|---|---|---|---|
| Import path rules | ✅ | ✅ | ✅ |
| Body analysis (what's called inside functions) | ✅ | ❌ | ❌ |
| Type checking (string vs typed union) | ✅ | ❌ | ❌ |
| Cycle detection | ✅ | ✅ | ❌ |
| Baseline (gradual adoption) | ✅ | ❌ | ❌ |
| GitHub PR annotations | ✅ | ❌ | ❌ |
What a Violation Looks Like
When a rule fails, you don't just get "error." You get why it matters and how to fix it:
Architecture Violation [1 of 1]
Rule: Classes extending 'BaseRepository' should not contain call to 'parseInt'
src/repositories/webhook.repository.ts:7 — WebhookRepository
Why: BaseRepository provides extractCount() — inline parseInt diverges
Fix: Replace parseInt(x, 10) with this.extractCount(result)
5 | async query() {
6 | const countResult = await this.db.count('* as count').first()
> 7 | const total = typeof countResult.count === 'string'
? parseInt(countResult.count, 10) : countResult.count
8 |In GitHub Actions, this appears inline on the PR diff — right where the violation was introduced.
Rules Read Like English
If you can read this sentence, you can write architecture rules:
typescript
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()The fluent API maps directly to the intent:
classes(p).that()— select which classes.extend('BaseRepository')— filter to subclasses.should().notContain(call('parseInt'))— assert what must be true.check()— run and fail if violated
Adopt Gradually
You don't need to fix every violation to start. Baseline mode records existing violations and only fails on new ones:
typescript
const baseline = withBaseline('arch-baseline.json')
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check({ baseline }) // only NEW violations failTeams adopt rules incrementally. As they fix legacy code, they regenerate the baseline to ratchet down.
AI Agents Need Guardrails
AI agents generating code don't know your team's conventions. Every agent PR looks correct in isolation — just like every human PR did.
ts-archunit is the guardrail. Rules 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.
Ready to Start?
Get Started → — install, write your first rule, run it in 5 minutes.
What Can It Check? — browse 21 categories of rules as one-liner examples.