Skip to content

Core Concepts

Before and After

This comparison shows what ts-archunit replaces. Manual AST traversal is verbose, error-prone, and produces poor error messages. The fluent DSL compresses the same logic into a single readable chain while adding code frames, violation context, and CI-friendly output for free.

Without ts-archunit, enforcing architecture means manual AST traversal with ts-morph:

typescript
// WITHOUT ts-archunit: 12 lines of manual AST traversal
import { Project, SyntaxKind } from 'ts-morph'

const project = new Project({ tsConfigFilePath: 'tsconfig.json' })
const classes = project
  .getSourceFiles()
  .flatMap((sf) => sf.getClasses())
  .filter((cls) => cls.getExtends()?.getExpression().getText() === 'BaseService')
for (const cls of classes) {
  for (const method of cls.getMethods()) {
    const calls = method.getDescendantsOfKind(SyntaxKind.CallExpression)
    if (calls.some((c) => c.getExpression().getText() === 'parseInt')) {
      throw new Error(`${cls.getName()} calls parseInt`)
    }
  }
}

With ts-archunit, the same rule is one fluent chain:

typescript
// WITH ts-archunit: 1 chain
classes(p).that().extend('BaseService').should().notContain(call('parseInt')).check()

The chain handles filtering, AST traversal, violation collection, code frame generation, and error formatting. You focus on what to enforce, not how to traverse the AST.

Two Ways to Run Rules

The same rule chain can run two ways. Pick by how your team already works — they enforce identical rules.

  • CLI rule file (the golden-path default). Rules live in arch.rules.ts as an export default [...] array of un-terminated builders; ts-archunit check (npm run arch) runs them. No test runner needed; baseline, diff-aware checks, and --format json/github built in.
  • Test file. Rules live in a vitest/jest test and end in .check(); your test runner runs them. Best if you already run tests in CI and want per-rule reporter output. See Running Rules in Tests.

They differ only in how a rule is terminated and run:

ConcernCLI rule file (arch.rules.ts)Test file (vitest)
Rule ends innothing — bare builder in the array.check()
Warning.asSeverity('warn').warn()
Baseline--baseline flag / config.check({ baseline })
Run withnpm run archnpx vitest run

WARNING

A builder ending in .check() inside a rule-file array is executed on the spot and returns undefined — the CLI silently skips it. In a rule file, leave builders un-terminated. See the conversion guide.

Examples on the rest of this page show the fluent chain ending in .check() (the test-file terminal) to illustrate execution; in a rule file, drop the .check() per the conversion above.

Project

Everything starts with loading a TypeScript project:

typescript
import { project } from '@nielspeter/ts-archunit'

const p = project('tsconfig.json')

The project is loaded once using ts-morph and cached per path. Subsequent calls to project('tsconfig.json') return the same instance. This means multiple rules in the same test file share the same loaded project -- no duplicate parsing.

Monorepo: workspace()

For monorepos with multiple tsconfigs, use workspace() to unify the import graph across packages:

typescript
import { workspace } from '@nielspeter/ts-archunit'

const p = workspace([
  'apps/web/tsconfig.json',
  'apps/api/tsconfig.json',
  'packages/shared/tsconfig.json',
])

workspace() returns a standard ArchProject — all entry points and conditions work unchanged. This makes cross-workspace imports visible to noDeadModules(), noUnusedExports(), and all dependency conditions.

Entry Points

Each entry point creates a rule builder for a specific kind of element:

Entry PointOperates OnUse Case
modules(p)Source filesImport/dependency rules
classes(p)Class declarationsInheritance, decorators, methods, body analysis
functions(p)Functions, arrow functions, methodsNaming, parameters, body analysis
types(p)Interfaces + type aliasesProperty types, type safety
slices(p)Groups of filesCycles, layer ordering
calls(p)Call expressionsFramework-agnostic route/handler matching
jsxElements(p)JSX elements in .tsx/.jsx filesDesign system compliance, accessibility, structural JSX conventions
within(sel)Scoped callbacksRules inside matched call callbacks

The Chain

Every rule follows the same pattern:

entryPoint(p).that().<predicates>.should().<conditions>.check()

Here's how each part works:

  1. entryPoint(p) -- selects what kind of element to check
  2. .that() -- starts the predicate phase (filtering)
  3. .should() -- starts the condition phase (asserting)
  4. .check() -- executes the rule and throws on violations
typescript
classes(p) // 1. entry point: class declarations
  .that() // 2. start filtering
  .extend('BaseService') // 2. predicate: only classes extending BaseService
  .should() // 3. start asserting
  .notContain(call('parseInt')) // 3. condition: must not call parseInt
  .check() // 4. execute

The grammar is a contract, not a convention: step 3 is required. A rule that stops at step 2 — or that puts a predicate where a condition belongs — asserts nothing about the code it selected, so it can never fail. Since 0.23.0 that is a configuration finding and fails the build, with no way to downgrade it. Predicates narrow; conditions assert; a rule needs both.

Predicates

Predicates filter which elements a rule applies to. They go between .that() and .should().

Identity Predicates

Available on all entry points:

PredicateDescription
haveNameMatching(re)Name matches a regex
haveNameStartingWith(s)Name starts with string
haveNameEndingWith(s)Name ends with string
resideInFile(glob)File path matches glob
resideInFolder(glob)Folder path matches glob
areExportedElement is exported
areNotExportedElement is not exported

Path Globs

resideInFolder(), resideInFile() and havePathMatching() accept two spellings, and they mean different things:

typescript
.resideInFolder('src/domain/**')      // that folder AT THE PROJECT ROOT (0.35.0+)
.resideInFolder('**/src/domain/**')   // any src/domain, anywhere in the project

The relative form resolves against the directory holding your tsconfig.json, so it is the narrower and usually the intended one — '**/src/domain/**' also matches a src/domain inside vendor/ or a nested package. Before 0.35.0 the relative spelling matched nothing; since 0.34.0 it was a hard failure telling you to anchor it.

A glob with a ./ segment is still an error in both forms — remove it.

Project-relative resolution is skipped when the project was not loaded from a tsconfig.json (an in-memory project has no root to be relative to), in which case only the absolute form matches.

Type-Specific Predicates

Each entry point adds its own predicates. See the dedicated pages: Classes, Functions, Types, Modules, Calls, JSX Elements.

Combining Predicates

Chain predicates with .and():

typescript
classes(p).that().extend('BaseRepository').and().resideInFolder('**/repositories/**').should()
// ...

Use combinators for complex logic:

typescript
import { and, or, not } from '@nielspeter/ts-archunit'

const myPredicate = or(extend('BaseService'), extend('BaseRepository'))
classes(p).that().satisfy(myPredicate).should(). /* ... */

Conditions

Conditions assert what must be true about the filtered elements. They go between .should() and .check().

Structural Conditions

ConditionDescription
notExist()No elements should match the predicates
beExported()All matched elements should be exported
haveNameMatching(re)All matched elements should match the regex
resideInFolder(glob)All matched elements should be in the folder
resideInFile(glob)All matched elements should be in the file

These names are dual-use — the same method is a predicate after .that() (filter) and a condition after .should() (assert), so they appear in both tables above. Position in the chain decides which:

typescript
// resideInFolder as a predicate: "of the classes in repositories/ …"
classes(p).that().resideInFolder('**/repositories/**').should().beExported().check()

// resideInFolder as a condition: "… must live in repositories/"
classes(p).that().extend('BaseRepository').should().resideInFolder('**/repositories/**').check()

Chaining Conditions

Use .andShould() for multiple conditions on the same selection:

typescript
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .beExported()
  .andShould()
  .notContain(call('parseInt'))
  .check()

Conditions accumulate: every condition in the chain is asserted, and each one that fails reports its own violations. Calling .should() a second time on the same chain adds to the assertions rather than replacing them — so the rule below checks both, and neither can be lost by reordering.

typescript
const repos = classes(p).that().extend('BaseRepository')

repos.should().beExported().should().notContain(call('parseInt')).check() // both asserted

What an Empty Selection Means

A condition reports a violation when some subject fails it. Over an empty subject set that is vacuously false, so before 0.34.0 a rule whose selector matched nothing passed — and the suite counted it as coverage.

typescript
// Before 0.34.0: green, and checked nothing at all.
classes(p)
  .that()
  .resideInFolder('**/repostories/**') // typo
  .should()
  .beExported()
  .check()

Since 0.34.0 an empty selection is a configuration finding: it fails, and the failure cannot be downgraded by .warn(), .asSeverity('warn'), .excluding(), a baseline, or diff-aware mode. A rule that selects nothing certifies nothing, and reporting that as a pass is the lie this library exists to remove.

Two things are legitimately empty, and both are exempt.

A rule whose condition asserts cardinality. .notExist() says "nothing matching this may exist", so zero subjects is the rule being satisfied:

typescript
// Passes, and keeps passing until someone creates the folder.
modules(p).that().resideInFolder('**/legacy/**').should().notExist().check()

The exemption requires every condition on the rule to be of that kind — andShould() ANDs, so a rule that also asserts something about subjects that exist is not satisfied by emptiness.

A selection you expect to be empty today. Declare it with .expectEmpty():

typescript
classes(p)
  .that()
  .haveDecorator('Deprecated')
  .expectEmpty() // nothing is deprecated yet
  .should()
  .beExported()
  .check()

.expectEmpty() is an assertion, not a silencer: it fails the day the selector matches something, so an intent that expires reports itself instead of going quiet forever. That is the whole difference from .allowEmpty(), which correspondence() shipped until plan 0097 converted it for exactly this reason — one word, silent forever, typo or not, and nothing revisits it. .expectEmpty() is now reachable on every family, not only the rule builders. Declaring .expectEmpty() and .expectNonEmpty() on the same rule is a contradiction and throws when the rule is built.

.expectNonEmpty() still exists and is still legal. It is now redundant, because it asks for the default.

Named Selections

Save a .that() chain and reuse it across rules:

typescript
const repositories = classes(p).that().extend('BaseRepository')

// Multiple rules on the same selection
repositories.should().notContain(call('parseInt')).check()
repositories.should().notContain(newExpr('Error')).check()
repositories.should().beExported().check()

Enforcement Model

MethodBehavior
.check()Fail on any violation
.warn()Log violations, don't fail (except configuration findings)
.check({ baseline })Fail only on new violations
.excluding(...)Permanently suppress named violations

.check() vs .warn()

  • .check() -- throws ArchRuleError on violations (test fails, CI blocks)
  • .warn() -- logs violations to stderr (test passes, advisory only) — with one exception (configuration findings)
typescript
// Hard rule: blocks CI
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()

// Soft rule: advisory
classes(p).that().haveDecorator('Deprecated').should().notExist().warn()

.excluding()

Permanently suppress specific violations while keeping the rule enforced for everything else:

typescript
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .notContain(call('parseInt'))
  .excluding('LegacyRepo', /Compat$/)
  .check()

See Violation Reporting for full details including inline exclusion comments.

Rule Metadata

Attach context to any rule with .rule():

typescript
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .notContain(call('parseInt'))
  .rule({
    id: 'repo/no-parseint',
    because: 'BaseRepository provides extractCount() which handles type coercion safely',
    suggestion: 'Replace parseInt(x, 10) with this.extractCount(result)',
    docs: 'https://example.com/adr/011',
  })
  .check()

All fields are optional. When present, they appear in violation output.

Composing with Combinators

The and(), or(), and not() combinators work on both predicates and conditions:

typescript
import { and, or, not, extend, implement, haveDecorator } from '@nielspeter/ts-archunit'

// Predicate combinators
const isService = or(extend('BaseService'), implement('IService'))
const isNotDeprecated = not(haveDecorator('Deprecated'))

classes(p).that().satisfy(and(isService, isNotDeprecated)).should().beExported().check()

Baseline Mode

Adopt rules in existing codebases without fixing every pre-existing violation:

typescript
import { withBaseline } from '@nielspeter/ts-archunit'

const baseline = withBaseline('arch-baseline.json')

// Only NEW violations fail -- existing ones are recorded in the baseline
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check({ baseline })

Generate a baseline from current violations:

typescript
import { collectViolations, generateBaseline } from '@nielspeter/ts-archunit'

const violations = collectViolations(rule1, rule2, rule3)
generateBaseline(violations, 'arch-baseline.json')

Diff-Aware Mode

Only report violations in files changed in the current PR:

typescript
import { diffAware } from '@nielspeter/ts-archunit'

classes(p)
  .should()
  .notContain(call('eval'))
  .check({ diff: diffAware('main') })

Next Steps

Released under the MIT License.