Skip to content
intermediate Phase 12 · Developer Tooling: Introspection, GraphiQL, and Codegen

Schema Linting and Style Rules

Enforcing naming, deprecation, and connection conventions with automated linters.

40m
1 problems
Topic Progress 0%

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

0 / 1 solved
Configure the Linter and Report Every Finding
Lint configuration

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?

Question 1 options

2. Why do schema rules and operation rules live in separate ESLint overrides?

Question 2 options

3. What does no-anonymous-operations protect besides code style?

Question 3 options

4. Which defect will schema linting never catch?

Question 4 options

Flashcards

Question

Two rule families

Answer

schema-recommended for SDL (naming, descriptions, type hygiene); operations-recommended for documents (anonymous ops, depth, unused fragments).

Question

no-typename-prefix

Answer

Flags type names leaking into their own fields: type User { userId } -> id. Enabled in schema-recommended.

Question

Why operation names matter to lint

Answer

no-anonymous-operations keeps telemetry, logs, and persisted-query keys attributable — usage data depends on it.

Question

graphql-config role

Answer

.graphqlrc.yml points editor and CI at the same schemas/documents so local and CI results match.

Question

Rollout ratchet

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

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.

Continue Learning