Conventions No Reviewer Can Enforce by Eye
Conventions No Reviewer Can Enforce by Eye
A schema review lands with four hundred lines of SDL. Somewhere in it, a type is userProfile instead of UserProfile, three fields have no description, one is named userId inside type User, and a deprecation was added without a reason. The reviewer catches one of the four, because scanning for style across hundreds of declarations is exactly the job humans are bad at.
Style drift is not cosmetic. Names are what developers type into completion all day; descriptions are what ends up in the docs panel; @deprecated without a reason leaves the next migration with nothing to follow. And the drift compounds: every inconsistent field becomes precedent for the next contributor.
A schema linter parses SDL and operation documents into an AST and applies configurable rules — the same architecture as ESLint, with GraphQL-specific rules. Two properties make it worth wiring up:
- Consistency across scale. One rule applied to 400 fields beats any number of review comments, and it applies identically to the 401st field added six months later.
- Different documents, different rules. Schema files are checked for naming, descriptions, and type hygiene; operation files are checked for structural mistakes (anonymous queries, unused fragments, runaway depth). The two sets barely overlap, which is why configurations use separate overrides per file pattern.
It helps to be precise about what linting is not. A linter does not know whether removing a field breaks a client — that is a compatibility question and belongs to schema checks in CI. It does not know whether a query returns the fields you intended — that is what TypeScript codegen turns into compile errors. Linting owns the middle layer: conventions and structural hygiene, enforced on every commit.
The Rule Set Worth Turning On
The Rule Set Worth Turning On
GraphQL ESLint ships recommended presets for each side — schema-recommended and operations-recommended — and the rules that earn their place early look like this:
# .graphqlrc.yml — one source of truth for editor and CI
schema: 'src/schema/**/*.graphql'
documents: 'src/**/*.{graphql,ts,tsx}'
// .eslintrc.cjs
module.exports = {
overrides: [
{
files: ['src/schema/**/*.graphql'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/require-description': 'error',
'@graphql-eslint/no-typename-prefix': 'error',
'@graphql-eslint/require-deprecation-reason': 'error',
'@graphql-eslint/naming-convention': ['error', {
types: 'PascalCase',
FieldDefinition: 'camelCase',
'FieldDefinition[gqlType.name.value=Boolean]': { style: 'camelCase', requiredPattern: '/^(is|has)/' },
}],
},
},
{
files: ['src/**/*.{ts,tsx}'],
extends: ['plugin:@graphql-eslint/operations-recommended'],
rules: {
'@graphql-eslint/no-anonymous-operations': 'error',
'@graphql-eslint/naming-convention': ['error', { OperationDefinition: 'PascalCase' }],
'@graphql-eslint/selection-set-depth': ['error', { max: 7 }],
},
},
],
};
What each rule buys:
| Rule | Catches |
|---|---|
naming-convention |
type user, firstName vs first_name, booleans that read like nouns — with per-selector options (fields returning Boolean must start with is/has) |
no-typename-prefix |
type User { userId: ID! } — the type name repeated inside its own fields; suggests id |
require-description |
Public types and fields with no doc text, so the docs panel has something to show |
require-deprecation-reason |
@deprecated with no migration hint — the field removal starts here |
no-anonymous-operations |
query { viewer { id } } — unnamed operations pollute server logs, telemetry, and persisted-query keys |
no-unused-fragments |
Fragments defined but never spread: dead documents that still ship |
selection-set-depth |
Accidentally unbounded nesting that turns one request into an expensive traversal |
The naming options are selector-based, which is what makes them practical: apply a stricter pattern only to FieldDefinition on the Query root, or require a prefix for a specific scalar's fields, instead of one global casing rule that every legitimate exception fights.
Making Lint Fail the Build
Making Lint Fail the Build
A lint configuration that only runs in someone's editor is a suggestion. Three steps turn it into an enforcement point.
1. Same config everywhere. The graphql-config file (.graphqlrc.yml) tells both the editor plugin and CI where schemas and documents live, so the squiggles a developer sees are the failures CI will report. Divergent configs are how "passes locally, fails in CI" erodes trust in the gate.
2. CI as one more npm script.
# .github/workflows/lint.yml
name: lint
on: [pull_request]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx eslint 'src/**/*.graphql' 'src/**/*.{ts,tsx}'
3. Ratchet, do not flood. Turning twelve new rules to error on an existing codebase fails every open pull request and teaches people to disable the linter. Start new rules at warn, fix the backlog in one dedicated cleanup, then promote to error — and use --max-warnings 0 in CI during the warn phase so the warning count cannot grow while you pay the backlog down.
Autofix coverage determines how fast that cleanup goes. Rules like alphabetize ship fixers; naming and description rules do not (renaming a public field is a compatibility event, not a formatting one — see the evolution rules matrix before running any bulk rename). Plan the cleanup as schema work with a review, not as a lint task.
Two structural habits keep the configuration healthy:
- Keep the overrides separate. Schema rules applied to operation files (or the reverse) produce errors that cannot be fixed without breaking the document, and the reflex becomes disabling the rule.
- Add project-specific rules sparingly. GraphQL ESLint supports custom rules; a local rule that requires a description on every root field or forbids a house prefix pays for itself only when reviewers keep catching the same thing manually.
Interview framing: "how do you enforce schema conventions?" Answer in three layers — a shared config consumed by editor and CI, rule sets split between schema and operations, and a ratchet from warn to error with a cleanup plan. Then name one rule and the specific defect it prevents; that separates tooling knowledge from tooling vocabulary.
Practice Problems
Given the schema and operation fragments below, write the ESLint configuration that would catch every violation present, and list each finding with the rule responsible. Include the CI invocation.
Example:
Input: type user { userId: ID! email: String } type Query { staleField: String @deprecated }
Output: naming-convention (types: PascalCase) on "user" · no-typename-prefix on userId · require-description on user, userId, email, Query, staleField · require-deprecation-reason on @deprecated
Each violation maps to a rule that sees it in a single AST pass; none of them require knowing what clients send.
Optimal Solution — O(n) time, O(n) space
Two overrides keyed by file pattern, each extending its preset and adding the specific rules the codebase agreed on, plus a shared graphql-config so the editor resolves the same files. CI runs ESLint over both globs with --max-warnings 0 once the backlog is clean.
// .eslintrc.cjs
module.exports = {
overrides: [
{
files: ['src/schema/**/*.graphql'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/naming-convention': ['error', { types: 'PascalCase', FieldDefinition: 'camelCase' }],
'@graphql-eslint/no-typename-prefix': 'error',
'@graphql-eslint/require-description': 'warn',
'@graphql-eslint/require-deprecation-reason': 'error',
},
},
{
files: ['src/**/*.{ts,tsx}'],
extends: ['plugin:@graphql-eslint/operations-recommended'],
rules: {
'@graphql-eslint/no-anonymous-operations': 'error',
'@graphql-eslint/selection-set-depth': ['error', { max: 7 }],
},
},
],
};
// findings against the sample
// type user -> @graphql-eslint/naming-convention (types: PascalCase)
// userId: ID! -> @graphql-eslint/no-typename-prefix (use id)
// email / userId / staleField-> @graphql-eslint/require-description
// @deprecated -> @graphql-eslint/require-deprecation-reason
// query { viewer { id } } -> @graphql-eslint/no-anonymous-operations
// CI
// npx eslint 'src/schema/**/*.graphql' 'src/**/*.{ts,tsx}' --max-warnings 0 Edge Cases:
- A Boolean field legitimately named without an is/has prefix (ignorePattern or selector scoping)
- Generated .graphql documents that should be excluded from linting
- A deprecation intentionally added without a reason during a rushed migration (warn before error)
Quiz
1. Which rule stops a schema from growing both firstName and first_name variants?
2. Why do schema rules and operation rules live in separate ESLint overrides?
3. What does no-anonymous-operations protect besides code style?
4. Which defect will schema linting never catch?
Flashcards
Question
Two rule families
Click to reveal answer
Answer
schema-recommended for SDL (naming, descriptions, type hygiene); operations-recommended for documents (anonymous ops, depth, unused fragments).
Question
no-typename-prefix
Click to reveal answer
Answer
Flags type names leaking into their own fields: type User { userId } -> id. Enabled in schema-recommended.
Question
Why operation names matter to lint
Click to reveal answer
Answer
no-anonymous-operations keeps telemetry, logs, and persisted-query keys attributable — usage data depends on it.
Question
graphql-config role
Click to reveal answer
Answer
.graphqlrc.yml points editor and CI at the same schemas/documents so local and CI results match.
Question
Rollout ratchet
Click to reveal answer
Answer
warn -> fix backlog in one cleanup -> error with --max-warnings 0; never flood an active repo with new errors.
Question
Lint vs schema check
Click to reveal answer
Answer
Lint judges one document’s style and structure; compatibility checks judge proposals against the published schema and client operations.
Revision Notes
Key Takeaways
- 1. Naming and description drift is a scale problem, and scale problems belong in automation
- 2. Split overrides by node vocabulary: SDL rules on schema files, execution rules on operations
- 3. Anonymous operations degrade the usage telemetry that schema evolution depends on
- 4. Enforcement means shared config plus CI plus a warn-to-error ratchet
Interview Tips
- • Name one rule and the concrete defect it prevents — interviewers distinguish vocabulary from experience here
- • Explain the boundary: lint catches style and structure, checks catch compatibility, codegen catches selection drift
- • Describe how you would introduce linting into a mature schema without breaking every open pull request
Cheat Sheet
Schema Linting Cheat Sheet
# .graphqlrc.yml
schema: 'src/schema/**/*.graphql'
documents: 'src/**/*.{graphql,ts,tsx}'
overrides: [
{ files: ['src/schema/**/*.graphql'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/require-description': 'error',
'@graphql-eslint/no-typename-prefix': 'error',
'@graphql-eslint/require-deprecation-reason': 'error',
'@graphql-eslint/naming-convention': ['error', { types: 'PascalCase', FieldDefinition: 'camelCase' }],
} },
{ files: ['src/**/*.{ts,tsx}'],
extends: ['plugin:@graphql-eslint/operations-recommended'],
rules: {
'@graphql-eslint/no-anonymous-operations': 'error',
'@graphql-eslint/selection-set-depth': ['error', { max: 7 }],
} },
]
| Rule | Defect |
|---|---|
| naming-convention | casing drift (userProfile, first_name) |
| no-typename-prefix | type User { userId } -> id |
| require-description | undocumented public fields |
| require-deprecation-reason | @deprecated with no migration hint |
| no-anonymous-operations | unattributable telemetry |
| selection-set-depth | unbounded nesting |
CI: npx eslint 'src/**/*.graphql' 'src/**/*.{ts,tsx}' with --max-warnings 0; roll out warn -> clean -> error.
Boundary: lint = style/structure · checks = compatibility · codegen = selection types.