Appearance
GraphQL Rules
Rule file or test file?
Snippets on this page end in .check() (the test-file form). In a CLI rule file (arch.rules.ts), drop .check() and spread the bare builder into export default [...] — a .check() inside a rule-file array is silently skipped. Use .asSeverity('warn') for warnings.
Enforce consistency across your GraphQL schema and resolvers. Shipped as a separate entry point with an optional peer dependency on the graphql package.
bash
npm install -D graphql # required for ts-archunit/graphqltypescript
import { schema, schemaFromSDL, resolvers } from '@nielspeter/ts-archunit/graphql'Schema Rules
Schema rules let you enforce structural conventions on your GraphQL types, queries, and mutations -- for example, requiring that all collection types carry pagination fields, or that every query field has a matching resolver. You start by loading your schema from .graphql files or inline SDL, then chain predicates and conditions just like any other ts-archunit rule.
Load from files
Use schema() to load .graphql files from disk via a glob pattern. This is the typical approach when your schema is split across multiple files in the repository.
typescript
import { schema } from '@nielspeter/ts-archunit/graphql'
import { project } from '@nielspeter/ts-archunit'
const p = project('tsconfig.json')
const s = schema(p, 'src/graphql/**/*.graphql')Load from inline SDL
Use schemaFromSDL() when you want to test against a schema defined directly in your test file. This is useful for unit-testing pattern templates or for projects that build their schema programmatically rather than from .graphql files.
typescript
import { schemaFromSDL } from '@nielspeter/ts-archunit/graphql'
const s = schemaFromSDL(`
type User {
id: ID!
name: String!
email: String!
}
type UserCollection {
items: [User!]!
total: Int!
skip: Int!
limit: Int!
}
type Query {
users: UserCollection!
user(id: ID!): User
}
`)Schema predicates
Predicates narrow the schema elements your rule targets. Use them to select specific categories -- all Query fields, all Mutation fields, types matching a name pattern, or fields that return lists. Without predicates, the rule applies to every element in the schema.
typescript
// Select all Query fields
s.that().queries()
// Select all Mutation fields
s.that().mutations()
// Select types by name pattern
s.that().typesNamed(/Collection$/)
// Select fields that return a list
s.that().returnListOf()Schema conditions
Conditions define the assertions enforced on schema elements that pass the predicate filter. They let you require specific fields on types, mandate pagination arguments on list queries, or verify that every query field has a corresponding resolver implementation.
typescript
// Collection types must have pagination fields
s.that()
.typesNamed(/Collection$/)
.should()
.haveFields('items', 'total', 'skip', 'limit')
.because('all collections use standard pagination envelope')
.check()
// List queries must accept pagination arguments
s.that()
.queries()
.and()
.returnListOf()
.should()
.acceptArgs('skip', 'limit')
.because('list endpoints must support pagination')
.check()
// Every Query field must have a matching resolver
s.that()
.queries()
.should()
.haveMatchingResolver('src/resolvers/**')
.because('unresolved query fields return null at runtime')
.check()Resolver Rules
Resolver rules let you enforce patterns inside your GraphQL resolver functions -- for example, requiring DataLoader usage for relation fields or banning direct database calls. They use the same body analysis engine as classes() and functions(), so you get access to call(), access(), newExpr(), and all other expression matchers.
typescript
import { resolvers } from '@nielspeter/ts-archunit/graphql'
import { call } from '@nielspeter/ts-archunit'
const p = project('tsconfig.json')
const r = resolvers(p, 'src/resolvers/**')The glob is relative to the tsconfig directory, unlike the file-path globs elsewhere in the library, which match absolute paths.
Each resolver is its own subject
A resolver map is an object literal, so its resolvers are property values rather than declarations. resolvers() collects them, named by the binding that owns the map plus the property path:
schemaResolvers.Query.assetCollection
schemaResolvers.Asset.urlThat is what lets a rule hold per resolver instead of per file. The distinction matters: a rule written against whole files passes as soon as any resolver in the file satisfies it, which reads as green over the ones that do not.
typescript
// Every collection resolver must bound its result set.
resolvers(p, 'src/resolvers/**')
.that()
.haveNameMatching(/Collection$/)
.should()
.contain(call('addComplexity'))
.rule({
id: 'graphql/collection-complexity',
because: 'an unbounded collection resolver is a fan-out denial-of-service',
suggestion: 'call addComplexity() with the requested page size',
})
.check()Prior to 0.19.0 resolvers() collected only named declarations, so on an idiomatic resolver map it selected the helper functions beside the resolvers and none of the resolvers themselves — see the upgrade note in the changelog.
Resolver predicates
typescript
// Select resolvers for fields that return object types
r.that().resolveFieldReturning(/^[A-Z]/)Resolver conditions
Resolver conditions reuse call(), newExpr(), access() from the body analysis engine:
typescript
// Resolvers for relation fields must use DataLoader
r.that()
.resolveFieldReturning(/^[A-Z]/)
.should()
.contain(call('loader.load'))
.because('prevent N+1 queries')
.check()
// Resolvers must not call database directly
r.should().notContain(call('db.query')).because('resolvers delegate to services').check()Real-World Examples
Consistent pagination
typescript
const s = schema(p, 'src/graphql/**/*.graphql')
// All collection types must have the standard envelope
s.that()
.typesNamed(/Collection$/)
.should()
.haveFields('items', 'total', 'skip', 'limit')
.check()
// All list queries must accept pagination args
s.that().queries().and().returnListOf().should().acceptArgs('skip', 'limit').check()DataLoader enforcement
typescript
const r = resolvers(p, 'src/resolvers/**')
// Relation resolvers must use DataLoader to prevent N+1
r.that()
.resolveFieldReturning(/^[A-Z]/)
.should()
.contain(call('loader.load'))
.rule({
id: 'graphql/use-dataloader',
because: 'Direct DB calls in resolvers cause N+1 query problems',
suggestion: 'Inject a DataLoader and call loader.load(id) instead',
})
.check()Schema-resolver sync
typescript
// Every query field must have a resolver implementation
s.that()
.queries()
.should()
.haveMatchingResolver('src/resolvers/**')
.rule({
id: 'graphql/resolver-coverage',
because: 'Unresolved query fields return null at runtime with no error',
})
.check()Requirements
graphqlpackage installed as a peer dependency (npm install -D graphql).graphqlschema files or inline SDL strings- Resolver files as standard TypeScript (analyzed via ts-morph)
The graphql package is optional — ts-archunit core works without it. Only install it if you use ts-archunit/graphql.