Appearance
Architecture Presets
Presets are parameterized architecture rule bundles. One function call generates multiple coordinated rules. Use presets as the starting point for new projects — they encode proven patterns from real production codebases.
typescript
import {
layeredArchitecture,
strictBoundaries,
dataLayerIsolation,
} from '@nielspeter/ts-archunit/presets'Every preset returns an array of rules (RuleBuilderLike[]) — one uniform model. Spread it into a CLI rule file, or run it in a test with checkAll:
typescript
// arch.rules.ts (CLI)
export default [...layeredArchitecture(p, { layers }), ...recommended(p)]
// or in a vitest/jest test
checkAll(layeredArchitecture(p, { layers }))The examples below show each preset's options; wire the call in with one of those two forms.
layeredArchitecture
The most universal architecture pattern. Nearly every backend project has layers — routes/controllers at the top, services in the middle, repositories/data access at the bottom. The rule is simple: dependencies flow downward, never upward. A repository must never import from a route. A service must never reach into the HTTP layer.
layeredArchitecture enforces this with a single function call. You define your layers in order (top to bottom) and it generates 5 coordinated rules: dependency direction, cycle freedom, innermost isolation, type-import enforcement, and package restrictions.
typescript
layeredArchitecture(p, {
layers: {
routes: '**/src/routes/**',
services: '**/src/services/**',
repositories: '**/src/repositories/**',
},
shared: ['**/src/shared/**', '**/src/utils/**'],
strict: true,
})Layer order matters — the first layer depends on the second, the second on the third, etc. In this example: routes → services → repositories. A repository importing from routes is a violation.
Generated rules
Each generated rule has a stable ID (for overrides) and a default severity. The preset runs all rules and aggregates violations — you see every problem in one error, not one rule at a time.
| Rule ID | What it enforces | Default |
|---|---|---|
preset/layered/layer-order | Dependencies flow inward only | error |
preset/layered/no-cycles | No circular dependencies between layers | error |
preset/layered/innermost-isolation | Innermost layer imports only from itself + shared (strict mode) | error |
preset/layered/type-imports-only | Cross-layer type imports allowed, value imports forbidden | warn |
preset/layered/restricted-packages | Only specified layers may import restricted packages | error |
strict mode
When strict: true, the innermost layer (last in the layers object) is fully isolated — it can only import from itself and the shared folders. This prevents repositories from reaching into services or routes.
typeImportsAllowed
Some layers need to reference types from other layers without taking a runtime dependency. typeImportsAllowed specifies which layers may use import type across layer boundaries:
typescript
layeredArchitecture(p, {
layers: { ... },
typeImportsAllowed: ['**/src/services/**'],
// Services can `import type { User } from '../repositories/user-repo.js'`
// but not `import { findUser } from '../repositories/user-repo.js'`
})restrictedPackages
Enforce that certain npm packages are only imported by specific layers. The key is the layer that IS allowed — all other modules in the project are forbidden:
typescript
layeredArchitecture(p, {
layers: { ... },
restrictedPackages: {
'**/src/repositories/**': ['knex', 'prisma'],
'**/src/infra/**': ['@aws-sdk/*'],
},
})This generates: "all modules NOT in src/repositories/** must not import knex or prisma". If multiple layers list the same package, the union of those layers may import it.
importOptions — aligning the type-import question
Since 0.59.0. Applies to layeredArchitecture and strictBoundaries.
These two presets construct conditions that disagree by default, on purpose:
beFreeOfCycles()ignores type-only imports. It asks whether the module is evaluated, and animport typeis erased at compile time, so it cannot contribute to an initialization cycle.respectLayerOrder()and the isolation conditions count them. They ask whether the code is coupled, and a shared type is coupling.
Holding a builder you choose per condition, where the distinction is visible. Through a preset it is invisible — so importOptions is one bag meaning this project's answer to "is a type-only edge a dependency?", applied to every rule the preset constructs whose condition takes one.
Two rules are excluded, because the bag has no answer to give them: preset/layered/type-imports-only (onlyHaveTypeImportsFrom asks about type imports as its subject) and preset/boundaries/no-duplicate-bodies (it compares function bodies, not imports). Every other rule in both presets is covered.
Passing it moves exactly one side, and which side depends on the value:
| you pass | the cycle rule | the layer / isolation rules |
|---|---|---|
| (nothing) | ignores type-only edges | counts them |
{ ignoreTypeImports: true } | unchanged | stops counting type coupling |
{ ignoreTypeImports: false } | starts counting type edges | unchanged |
ts
layeredArchitecture(project, {
layers: { routes: '**/routes/**', services: '**/services/**' },
// Our team treats a shared type as a real dependency everywhere.
importOptions: { ignoreTypeImports: false },
})dataLayerIsolation
Companion to layeredArchitecture. Enforces repository pattern conventions that layer ordering alone cannot catch: base class extension and typed error throwing.
typescript
dataLayerIsolation(p, {
repositories: '**/src/repositories/**',
baseClass: 'BaseRepository',
requireTypedErrors: true,
})Generated rules
| Rule ID | What it enforces | Default |
|---|---|---|
preset/data/extend-base | All classes in repositories extend the base class | error |
preset/data/typed-errors | No new Error() in repositories — use typed errors | error |
Both rules are optional — omit baseClass to skip the extension check, omit requireTypedErrors to skip the error check. Omitting both is different from skipping one: with neither flag set this call constructs zero rules, and reports a preset/data/constructs-nothing configuration finding rather than passing silently — the same finding agentGuardrails reports below, for the same reason. Set at least one flag to enforce anything.
strictBoundaries
For projects with distinct feature areas (modules, bounded contexts, packages). Prevents cross-contamination between boundaries.
typescript
strictBoundaries(p, {
folders: '**/src/features/*',
shared: ['**/src/shared/**', '**/src/lib/**'],
isolateTests: true,
noCopyPaste: true,
})Generated rules
| Rule ID | What it enforces | Default |
|---|---|---|
preset/boundaries/no-cycles | No circular deps between boundary folders | error |
preset/boundaries/no-cross-boundary | Each boundary imports only from itself + shared | error |
preset/boundaries/shared-isolation | Shared folders don't import from boundaries | error |
preset/boundaries/test-isolation | Test files don't import from other boundaries' tests | error |
preset/boundaries/no-duplicate-bodies | No copy-pasted function bodies across boundaries | warn |
Boundary folders are discovered dynamically from the glob pattern. src/features/* finds all immediate subdirectories under src/features/.
recommended
A deliberately thin, universal safety floor for any TypeScript project — the handful of things dangerous regardless of project shape that fire ~never on healthy code. It is not a full architecture; shape-specific rules (layer order, cycles, delegation) are yours to add.
Like every preset, it returns severity-carrying builders, so spread it into the default export:
typescript
import { project } from '@nielspeter/ts-archunit'
import { recommended } from '@nielspeter/ts-archunit/presets'
const p = project('tsconfig.json')
export default [
...recommended(p),
// ...your shape-specific rules
]Generated rules
| Rule ID | Enforces | Default |
|---|---|---|
preset/recommended/no-eval | No eval() | error |
preset/recommended/no-function-constructor | No Function constructor | error |
preset/recommended/no-silent-catch | No empty/silent catch blocks | warn |
preset/recommended/no-empty-bodies | No empty function bodies | warn |
Two error, two warn. The warn rules have known, suppressible false positives (intentional empty catches, no-op callbacks), so they surface without failing the build.
Options. include is the source glob (default '**/src/**', matched against each file's absolute path). A **/src/** glob already covers monorepos — packages/foo/src/** matches at any depth — so only projects whose source lives outside any src/ folder (e.g. lib/) need to override it:
typescript
export default [...recommended(p, { include: '**/lib/**' })]The overrides map (below) changes individual rule severity. Codegen, templating, or serializer libraries that legitimately build functions from strings should turn off the Function-constructor rule: overrides: { 'preset/recommended/no-function-constructor': 'off' }. (eval has no comparable legitimate use, so it stays error.)
Adoption. The floor is designed to fire ~never on healthy code, so adopting it is usually a non-event. If a legacy codebase does trip the rules — an existing eval, or a wall of empty catches — snapshot them once with --baseline; the baseline captures all four severities, so only new violations surface afterward (see Baseline).
Stability. recommended is a versioned contract, not just a convenience alias: spreading ...recommended(p) means "these four rules today, and we won't break your CI on a minor bump." New rules enter at warn or off in a minor release and are only promoted to error in a major. That opt-in ladder is the reason to depend on the preset rather than hand-copy the four rules.
Overlaps
agentGuardrailson empty bodies and (if you list'eval'in itsnoInlineLogic)eval. Running both double-reports those locations under different rule ids. For agent-focused projects preferagentGuardrailsalone; otherwise silence therecommendedcopies —overrides: { 'preset/recommended/no-empty-bodies': 'off' }.
agentGuardrails
Targets the mistakes AI coding agents make most often — inline logic, generic errors, stub comments, empty bodies, copy-paste. Where the presets above enforce where code goes, agentGuardrails enforces how it is written. See AI Agents for the full workflow.
Like every preset, it returns severity-carrying builders, so you spread it into a rule file's default export:
typescript
import { project } from '@nielspeter/ts-archunit'
import { agentGuardrails } from '@nielspeter/ts-archunit/presets'
const p = project('tsconfig.json')
export default [
...agentGuardrails(p, {
src: '**/src/**',
noInlineLogic: ['parseInt', 'JSON.parse', 'eval'],
noGenericErrors: true,
noStubs: true,
noEmptyBodies: true,
noCopyPaste: true,
}),
]Generated rules
| Rule ID | Enforces | Default |
|---|---|---|
preset/agent/no-inline-logic/<api> | No inline call to a banned API (one per entry) | error |
preset/agent/no-generic-errors | No throw new Error() — use typed errors | error |
preset/agent/no-stubs | No TODO/FIXME/"not implemented" stub comments | error |
preset/agent/no-empty-bodies | No empty function bodies | error |
preset/agent/no-copy-paste | No near-identical function bodies (≥ 0.9 similarity) | warn |
Uses function-variant rules, so standalone functions, arrow functions, and class methods are all covered. Each rule carries because / suggestion / imperative metadata so the agent gets an actionable fix in explain --format agent and check --format json. Accepts the same overrides map as every preset (below).
Every rule above sits behind its own optional flag — nothing is unconditional. Calling agentGuardrails(p, { src }) with none of them set constructs zero rules, and reports a preset/agent/constructs-nothing configuration finding rather than passing silently: satisfying the required src field is not the same as enforcing anything. Set at least one flag to enforce anything.
agentGuardrailsoverlaps a generalrecommendedfloor on empty bodies andeval. Running both double-reports those locations (different rule ids). For agent-focused projects, preferagentGuardrailsalone; otherwise override the duplicated ids to'off'in one preset.
Overrides
Every preset accepts overrides to change individual rule severity:
typescript
layeredArchitecture(p, {
layers: { ... },
overrides: {
'preset/layered/type-imports-only': 'off', // disable completely
'preset/layered/no-cycles': 'warn', // downgrade to warning
},
})Three severity levels: 'error' (fails the run), 'warn' (reported but never fails a rule that works — surfaces in terminal / JSON / GitHub output and is baseline-filterable), 'off' (skipped entirely). Unrecognized override keys emit a warning — catches typos.
'warn' does not cover a rule that cannot enforce anything. A configuration finding — a dead glob, a missing assertion, or (since 0.59.0) a rule that examined zero units — is reported at error regardless of the severity you set, and overrides: { id: 'warn' } cannot downgrade it. The distinction is deliberate: 'warn' grades how strictly you want violations of a working rule treated, and a rule that enforces nothing has no violations to grade. Four shipped preset rules default to 'warn' and are affected — preset/agent/no-copy-paste, preset/boundaries/no-duplicate-bodies, preset/recommended/no-silent-catch and preset/recommended/no-empty-bodies.
Before reaching for 'off', read the next section. If the rule is red because it has nothing to check — not because you disagree with it — expectEmpty says that and expires when it stops being true. 'off' deletes the rule and never expires.
expectEmpty — declaring that a rule has nothing to check
Since 0.59.0. Applies to every preset.
A rule that examines nothing passes without checking anything. When you hold a builder you say so with .expectEmpty(); a preset user holds none, so presets accept the same declaration by rule id:
ts
agentGuardrails(project, {
src: '**/src/**',
noCopyPaste: true,
// This package has no duplicate-body surface yet — say so, rather than
// disabling the rule and forgetting.
expectEmpty: ['preset/agent/no-copy-paste'],
})Prefer it to overrides: { id: 'off' }. off deletes the rule permanently; expectEmpty states a fact about today that a later release can hold you to. Three guardrails come with it:
- An id that names no constructed rule fails, unsuppressably — including a rule you also set to
'off', sinceoffmeans it was never built and the declaration applies to nothing. - A dead glob is not declarable. A selector that can never match is a mistake, not a state, and no declaration hides it.
- A false declaration fails, and does not hide what it was covering. The day the selection fills you get an unsuppressable finding naming the id — and the rule's real violations are reported underneath it, not swallowed by it.
One id can name several rules
strictBoundaries and layeredArchitecture construct some ids many times — no-cross-boundary once per boundary, restricted-packages once per package, test-isolation once per boundary pair. A declaration applies to every instance, so it holds only while all of them examine nothing:
ts
strictBoundaries(project, {
folders: '**/src/features/*',
// True only while NO boundary imports across. The day one does, this fails
// for that boundary — and that boundary's violations are reported too.
expectEmpty: ['preset/boundaries/no-cross-boundary'],
})If some instances are empty and others are not, the declaration is the wrong tool: narrow the option that generates them instead.
Aggregated errors
A preset's rules all flow through the same runner (check, or checkAll in a test), so you see every violation across every rule in one report — not just the first failing rule. This makes fixing violations much faster: the full picture on every run.
When to use presets vs. custom rules
Use presets when your project follows a recognized pattern (layered architecture, feature modules, repository pattern). Use custom rules when you need project-specific constraints that presets don't cover.
Presets and custom rules compose freely — spread them into the same rule file:
typescript
// arch.rules.ts
export default [
// Presets handle the structural rules
...layeredArchitecture(p, { layers: { ... } }),
...strictBoundaries(p, { folders: '**/src/features/*' }),
// Custom rules handle project-specific concerns (builders, no .check())
functions(p).that().resideInFolder('**/services/**').should().satisfy(mustCall(/Repository/)),
]