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.

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.

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
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

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

Type-Specific Predicates

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

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
conditionHaveNameMatching(re)All matched elements should match the regex
shouldResideInFolder(glob)All matched elements should be in the folder
shouldResideInFile(glob)All matched elements should be in the file

Chaining Conditions

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

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

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
.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)
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.