Appearance
Body Analysis
Body analysis is the signature feature of ts-archunit. While other tools check import paths, ts-archunit inspects what happens inside function and method bodies -- the actual AST of call expressions, constructor invocations, property access, and arbitrary expressions.
What Body Analysis Is
Most architecture tools operate at the module level: "file A imports file B." That catches dependency violations but misses patterns like:
- A repository calling
parseInt()instead of its base class helper - A service throwing
new Error()instead of a typed domain error - A wrapper constructing
new URLSearchParams()instead of using the shared utility - Production code calling
console.log
Body analysis fills this gap. It traverses the AST inside every method body (for classes) or function body (for functions) and matches against expression patterns you define.
Matchers
Matchers are the building blocks of body analysis rules. Each matcher targets a specific kind of AST node -- function calls, constructor invocations, property access, object properties, or arbitrary expressions. You pass a matcher to a condition like notContain() to define what should (or should not) appear inside method and function bodies.
Five matchers cover the most common expression patterns:
call(target)
Matches function and method call expressions. Use this to ban specific function calls (like parseInt or console.log) or require that certain methods are invoked (like this.validate). This is the most frequently used matcher.
typescript
import { call } from '@nielspeter/ts-archunit'
call('parseInt') // matches parseInt(x, 10)
call('console.log') // matches console.log('hello')
call('this.extractCount') // matches this.extractCount(result)
call(/^parse/) // matches parseInt, parseFloat, parseSomethingnewExpr(target)
Matches constructor invocations (new ...). Use this to forbid direct instantiation of certain classes -- for example, banning new Error() in favor of typed domain errors, or banning new URLSearchParams() in favor of a shared utility.
typescript
import { newExpr } from '@nielspeter/ts-archunit'
newExpr('Error') // matches new Error('message')
newExpr('URLSearchParams') // matches new URLSearchParams(params)
newExpr('Function') // matches new Function('return 1')
newExpr(/^(?!Typed)Error$/) // matches new Error but not new TypeErroraccess(target)
Matches property access expressions. Use this to detect direct access to globals like process.env or document, which should typically go through an abstraction layer for testability and portability.
typescript
import { access } from '@nielspeter/ts-archunit'
access('process.env') // matches process.env.DATABASE_URL
access('this.config') // matches this.config.timeout
access(/^document\./) // matches document.querySelector, document.getElementByIdproperty(name, value?)
Matches property assignments in object literals by name and optional value. Use this when your architectural rules target configuration or schema definitions rather than executable code -- for example, ensuring JSON schemas always set additionalProperties: false, or that config objects use specific modes. Reach for property() instead of expression() when you need to match a specific key-value pair in an object literal.
typescript
import { property } from '@nielspeter/ts-archunit'
property('additionalProperties', true) // matches additionalProperties: true
property('type', 'object') // matches type: 'object' (no quotes needed)
property('maximum', 100) // matches maximum: 100
property(/^additional/) // matches any property starting with 'additional'
property('mode', /^'(strict|loose)'$/) // matches mode: 'strict' or 'loose' (RegExp uses raw getText())Value matching uses semantic comparison for primitives (boolean, number, string via getLiteralValue()). RegExp values match against the raw source text including quotes. Omit the value parameter for name-only matching.
Note:
property()targetsPropertyAssignmentnodes. It does not match shorthand properties ({ schema }) or computed property names ({ [key]: value }).
expression(target)
Matches any expression by its raw source text. This is the catch-all matcher -- use it as a fallback when call(), newExpr(), access(), and property() do not cover your case. Because it matches against getText() output, it is less precise than the specialized matchers but more flexible.
typescript
import { expression } from '@nielspeter/ts-archunit'
expression('eval') // matches eval('code')
expression(/JSON\.parse/) // matches JSON.parse(str)String vs Regex
All matchers accept either a string (exact match) or a regex (pattern match):
typescript
// Exact match -- only parseInt
call('parseInt')
// Pattern match -- parseInt, parseFloat, parseSomething
call(/^parse/)
// Exact match -- only new Error
newExpr('Error')
// Pattern match -- new Error, new TypeError, new RangeError
newExpr(/Error$/)Optional Chaining
Optional chaining is automatically normalized. this?.foo matches the same pattern as this.foo:
typescript
// Both of these match:
// this.extractCount(result)
// this?.extractCount(result)
call('this.extractCount')Conditions
Conditions combine with matchers to form the .should() clause of a body analysis rule. They determine whether a matched expression must be present, must be absent, or should be replaced by an alternative.
contain(matcher)
Asserts that every matched class or function must include at least one occurrence of the matched expression in its body. Use this to enforce that certain methods are always called -- for example, requiring that all repositories call this.validate.
typescript
classes(p).that().extend('BaseRepository').should().contain(call('this.validate')).check()notContain(matcher)
Asserts that the matched expression must not appear anywhere in the body. This is the most common body analysis condition -- use it to ban unsafe functions, raw constructors, or direct access to globals that should go through an abstraction.
typescript
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()useInsteadOf(banned, replacement)
Combines a ban with a suggested alternative. It asserts the banned expression is absent and includes the replacement in the violation message as guidance. Use this instead of a bare notContain() when there is a clear migration path -- it produces more actionable violation messages.
typescript
classes(p)
.that()
.extend('BaseRepository')
.should()
.useInsteadOf(call('parseInt'), call('this.extractCount'))
.rule({
id: 'repo/no-parseint',
because: 'BaseRepository provides extractCount() which handles type coercion safely',
suggestion: 'Replace parseInt(x, 10) with this.extractCount(result)',
})
.check()Class vs Function Scope
Body analysis works on both classes and functions, but the scope differs:
classes(p)-- checks all method bodies in each matched classfunctions(p)-- checks the body of each matched function/arrow/method individually
typescript
// Check class method bodies
classes(p).that().extend('BaseService').should().notContain(newExpr('Error')).check()
// Check function bodies (includes standalone functions AND arrow functions)
functions(p)
.that()
.resideInFolder('**/wrappers/**')
.should()
.notContain(newExpr('URLSearchParams'))
.check()Advanced: Standalone Body Analysis Conditions
For composition with custom rules, standalone condition functions are available:
typescript
import {
classContain,
classNotContain,
classUseInsteadOf,
functionContain,
functionNotContain,
functionUseInsteadOf,
} from '@nielspeter/ts-archunit'These return Condition<ClassDeclaration> or Condition<ArchFunction> that can be passed to .satisfy() or combined with other conditions.
Real-World Examples
Ban parseInt -- Require Shared Helper
typescript
classes(p)
.that()
.extend('BaseRepository')
.should()
.useInsteadOf(call('parseInt'), call('this.extractCount'))
.rule({
id: 'repo/no-parseint',
because: 'BaseRepository provides extractCount() which handles type coercion safely',
suggestion: 'Replace parseInt(x, 10) with this.extractCount(result)',
})
.check()Ban new Error() -- Require Typed Domain Errors
typescript
classes(p)
.that()
.extend('BaseService')
.should()
.notContain(newExpr('Error'))
.rule({
id: 'error/typed-errors',
because: 'Generic Error loses context and prevents consistent API error responses',
suggestion: 'Use NotFoundError, ValidationError, or DomainError instead',
})
.check()Ban new URLSearchParams() -- Require Utility
typescript
functions(p)
.that()
.resideInFolder('**/wrappers/**')
.should()
.notContain(newExpr('URLSearchParams'))
.rule({
id: 'sdk/no-raw-urlsearchparams',
suggestion: 'Use buildQueryString() utility',
})
.check()Ban console.log in Production Code
typescript
import { noConsoleLog } from '@nielspeter/ts-archunit/rules/security'
classes(p)
.that()
.resideInFolder('**/src/**')
.should()
.satisfy(noConsoleLog())
.because('use a logger abstraction')
.check()Ban eval()
typescript
import { noEval } from '@nielspeter/ts-archunit/rules/security'
classes(p).should().satisfy(noEval()).because('eval is a security risk').check()Ban process.env in Domain Layer
typescript
import { noProcessEnv } from '@nielspeter/ts-archunit/rules/security'
classes(p)
.that()
.resideInFolder('**/domain/**')
.should()
.satisfy(noProcessEnv())
.because('use dependency injection for configuration')
.check()Ban new Function() Constructor
typescript
import { noFunctionConstructor } from '@nielspeter/ts-archunit/rules/security'
classes(p)
.should()
.satisfy(noFunctionConstructor())
.because('new Function() is equivalent to eval')
.check()Scoped Body Analysis with within()
Check what happens inside callback functions of specific call expressions:
typescript
import { calls, call, within } from '@nielspeter/ts-archunit'
const routes = calls(p)
.that()
.onObject('app')
.and()
.withMethod(/^(get|post|put|delete|patch)$/)
// Within route handlers, enforce error handling
within(routes).functions().should().contain(call('handleError')).check()Known Limitations
- Destructured calls are not matched.
const { parse } = JSON; parse(str)won't matchcall('JSON.parse'). - No cross-file tracing. Body analysis inspects the AST of the current file. If a function delegates to another file, that delegation is not followed.
- Dynamic expressions like
obj[methodName]()are not matchable by name. expression()deduplicates ancestor matches. Sinceexpression()walks all AST descendants, parent nodes whosegetText()contains the same pattern are automatically filtered out — only the deepest matching node is reported. This prevents inflated violation counts (e.g., onereply.code(400)producing 10+ violations from ancestor nodes).