Skip to main content

Best Practices for Documenting GraphQL Schemas with SDL

Undocumented GraphQL SDL causes CI to fail at the schema:validate stage with errors such as GraphQLError: Field 'userPreferences' requires a description or produces codegen output where @graphql-codegen/cli 5 emits incomplete TypeScript types, propagating TS2339: Property does not exist errors into the frontend build. This guide extends REST vs GraphQL vs gRPC Contract Strategies and shows exactly which SDL annotations fix those failures, how to enforce them automatically, and how to evolve a documented schema without breaking consumers.

Why SDL Documentation Fails in Practice

GraphQL SDL has two mechanisms that look like comments but behave completely differently. Hash comments (# ...) are stripped by the parser at lex time and do not appear in the AST. They are invisible to introspection, GraphiQL, and every code generator. Triple-quoted strings ("""...""") are parsed as StringValue nodes that populate the description property of the type or field definition. Every tool that reads the SDL — graphql-codegen, graphql-inspector, schema differs — reads from the AST description property. When it is null, strict validation gates reject the schema.

The second common failure: @deprecated without a reason argument. The directive itself is valid, but downstream tooling cannot construct a migration path for consumers, and some governance rulesets treat a bare @deprecated as a lint error because it provides no actionable information.

Unlike Defining gRPC Service Contracts with Protocol Buffers — where the binary wire format enforces the contract — GraphQL SDL is both the executable schema and the documentation artifact. There is no separate OpenAPI-style description block; every piece of metadata lives inline in the type system. This makes descriptions load-bearing, not cosmetic.

Step 1: Audit the SDL for Missing Descriptions

Before editing, get a complete inventory. Run graphql-eslint 3.20 in warn mode against the schema to list every undocumented element without blocking anything yet:

# Install graphql-eslint 3.20 (requires Node.js 20+)
npm install --save-dev @graphql-eslint/eslint-plugin@3.20 graphql@16.9 eslint@8
// .eslintrc.json — audit configuration (warn only)
{
  "overrides": [
    {
      "files": ["*.graphql"],
      "parser": "@graphql-eslint/eslint-plugin",
      "plugins": ["@graphql-eslint"],
      "rules": {
        "@graphql-eslint/require-description": [
          "warn",
          {
            "types": true,
            "FieldDefinition": true,
            "InputValueDefinition": true,
            "EnumValueDefinition": true
          }
        ]
      }
    }
  ]
}
npx eslint "**/*.graphql" --no-eslintrc -c .eslintrc.json 2>&1 | grep "require-description"

The output lists every type, field, argument, and enum value that is missing a description. Pipe it to a file and work through it systematically. A schema that has never been documented typically shows hundreds of warnings; treat this list as the work backlog before switching the rule to error in CI.

Step 2: Add Triple-Quoted Descriptions

The rule is simple: every named SDL construct gets a """...""" string placed immediately before it. The first sentence must stand alone as a summary — introspection tools often truncate after the first period. Subsequent sentences add context, constraints, or usage notes.

Here is a fully annotated type covering every SDL construct category:

"""
Represents a registered user account in the platform.

Do not expose this type directly to unauthenticated clients;
use the `PublicProfile` type for public-facing surfaces.
"""
type User {
  """The globally unique identifier for this user. Stable across renames."""
  id: ID!

  """The user's primary email address. Validated on write; immutable after creation."""
  email: String!

  """
  Secondary contact email for billing notifications.
  Null when the user has not supplied a secondary address.
  """
  secondaryEmail: String

  """
  The user's subscription tier. Deprecated — use `plan` instead.
  Kept resolvable until 2026-12-01 to give existing clients time to migrate.
  """
  tier: SubscriptionTier @deprecated(reason: "Use the `plan` field instead. Removed after 2026-12-01.")

  """The user's current subscription plan, including billing cycle and feature flags."""
  plan: Plan!

  """Timestamp of the most recent successful authentication, in ISO 8601 UTC."""
  lastLoginAt: String
}

"""
The legacy subscription tiers, superseded by the Plan type.
Retained only for backward compatibility with v1 clients.
"""
enum SubscriptionTier {
  """Free tier with rate-limited API access."""
  FREE

  """Paid tier with expanded limits and SLA guarantees."""
  PRO

  """Enterprise tier with dedicated infrastructure and custom SLAs."""
  ENTERPRISE
}

"""
Input for creating or updating a user's profile information.
All fields are optional; omitted fields are not modified.
"""
input UpdateUserInput {
  """New display name. Maximum 128 characters. Sanitized server-side."""
  displayName: String

  """New secondary email. Must pass MX record validation before persisting."""
  secondaryEmail: String
}

type Query {
  """
  Fetch a single user by their unique identifier.
  Returns null when no user exists with the given id.
  """
  user(
    """The unique identifier of the user to fetch."""
    id: ID!
  ): User

  """
  List all users matching the filter criteria.
  Paginated; default page size is 20. Maximum is 100.
  """
  users(
    """Optional filter applied to email address prefix."""
    emailPrefix: String
    """Number of results per page. Defaults to 20."""
    limit: Int
    """Opaque cursor for pagination, returned by a previous `users` call."""
    after: String
  ): UserConnection!
}

Why this works: Every description AST node is now populated. graphql-codegen 5 emits these as JSDoc comments in generated TypeScript, making the type file self-documenting. graphql-inspector can diff descriptions and surface changes in PR reviews. The @deprecated directive includes a reason string with a removal date, giving consumers a migration target and a timeline — both required by the governance rules.

Step 3: Naming Conventions That Prevent Codegen Ambiguity

SDL naming conventions are not just style. Inconsistent names produce ambiguous codegen output: two types named userInput and UserInput may collide in case-insensitive file systems, and snake_case field names generate TypeScript properties that do not match JavaScript’s camelCase convention.

The SDL naming standard:

Construct Convention Example
Object, interface, union types PascalCase OrderLineItem, UserProfile
Field names camelCase createdAt, totalAmount
Enum type names PascalCase OrderStatus, PaymentMethod
Enum values SCREAMING_SNAKE_CASE PENDING_PAYMENT, SHIPPED
Input type names PascalCase + Input suffix CreateOrderInput, UpdateUserInput
Query/Mutation/Subscription fields camelCase, verb-prefixed for mutations getOrder, createOrder, cancelOrder
Arguments camelCase orderId, pageSize

Enforce these with graphql-eslint 3.20 naming rules:

{
  "rules": {
    "@graphql-eslint/naming-convention": [
      "error",
      {
        "types": "PascalCase",
        "FieldDefinition": "camelCase",
        "EnumValueDefinition": "SCREAMING_SNAKE_CASE",
        "InputValueDefinition": "camelCase",
        "allowLeadingUnderscore": false
      }
    ]
  }
}

Step 4: Nullability — Document the Contract, Not the Implementation

Nullability in GraphQL SDL is a contract statement about what consumers can rely on, not a reflection of what the database column allows. Every non-nullable field (Type!) is a promise that the resolver will always return a value. Break that promise and the entire response object nulls out, propagating the error upward to the nearest nullable parent.

Rules to follow:

  • Mark a field non-nullable only when the resolver guarantees a value — not just usually provides one.
  • Nullable fields must document when the value is null, not just that it can be.
  • List fields ([Item!]!) distinguish between “the list is always present but may be empty” ([Item!]!) and “the field itself may be absent” ([Item]). Use [Item!]! for collections unless the absence of the list field is semantically distinct from an empty list.
  • Connection types follow the Relay spec: edges and node are nullable by convention because a cursor may resolve to a deleted item; document this explicitly.
type Order {
  """The order's unique identifier. Always present; never null."""
  id: ID!

  """
  The shipping address captured at order creation.
  Null for digital-only orders that require no physical delivery.
  """
  shippingAddress: Address

  """
  Line items in this order. Always returns a list (empty if the order has no items).
  Individual items are guaranteed non-null.
  """
  lineItems: [OrderLineItem!]!

  """
  Third-party tracking number from the carrier.
  Null until the order has been handed off to the carrier.
  """
  trackingNumber: String
}

The inline documentation on nullable fields is the contract: it tells consumer teams exactly when to expect null without needing to read server source code or file a support ticket.

Step 5: Enforce Documentation in CI with graphql-eslint

Switch the require-description rule from warn to error once the backlog from Step 1 is cleared. Add a dedicated lint job that runs on every pull request:

// .eslintrc.json — enforcement configuration
{
  "overrides": [
    {
      "files": ["*.graphql", "**/*.graphql"],
      "parser": "@graphql-eslint/eslint-plugin",
      "plugins": ["@graphql-eslint"],
      "parserOptions": {
        "schema": "./schema.graphql"
      },
      "rules": {
        "@graphql-eslint/require-description": [
          "error",
          {
            "types": true,
            "FieldDefinition": true,
            "InputValueDefinition": true,
            "EnumValueDefinition": true,
            "DirectiveDefinition": true
          }
        ],
        "@graphql-eslint/naming-convention": [
          "error",
          {
            "types": "PascalCase",
            "FieldDefinition": "camelCase",
            "EnumValueDefinition": "SCREAMING_SNAKE_CASE",
            "InputValueDefinition": "camelCase"
          }
        ],
        "@graphql-eslint/deprecation-reason": "error",
        "@graphql-eslint/no-hashtag-comment": "warn"
      }
    }
  ]
}

The deprecation-reason rule blocks a bare @deprecated with no reason argument. The no-hashtag-comment rule warns when a developer writes # ... expecting it to appear in introspection.

# .github/workflows/graphql-lint.yml
name: GraphQL Schema Lint
on:
  pull_request:
    branches: [main]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - name: Lint GraphQL SDL
        run: npx eslint "**/*.graphql"
      - name: Codegen dry-run
        run: npx graphql-codegen --check
        # --check exits non-zero if generated files would change, catching description drift

The codegen dry-run (--check) is the second gate: it catches cases where a developer edited descriptions but forgot to regenerate the TypeScript types. Both gates must be green for a PR to merge.

Step 6: Configure graphql-codegen to Emit Descriptions as JSDoc

Descriptions written in SDL should surface in generated TypeScript so the documentation travels with the type. Configure @graphql-codegen/cli 5 to map SDL description nodes to JSDoc comments:

// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "./schema.graphql",
  generates: {
    "./src/types/graphql.ts": {
      plugins: ["typescript", "typescript-operations"],
      config: {
        // Map SDL descriptions to JSDoc /** */ comments in generated types
        commentDescriptions: true,
        // Emit @deprecated JSDoc tags for deprecated fields
        disableDescriptions: false,
        // Strict scalar types — no implicit `any` for custom scalars
        strictScalars: true,
        scalarsModule: "./src/scalars",
      },
    },
    // Resolver type map — descriptions appear as JSDoc on resolver signatures
    "./src/types/resolvers.ts": {
      plugins: ["typescript", "typescript-resolvers"],
      config: {
        commentDescriptions: true,
        strictScalars: true,
      },
    },
  },
};

export default config;

The result: every generated User interface includes a /** The globally unique identifier for this user. Stable across renames. */ comment above the id field. IDEs surface the description on hover, making the generated types as informative as the SDL source.

Before/After Comparison

The clearest illustration is a type that passes schema:validate versus one that fails it.

Before — fails require-description lint, breaks codegen JSDoc output:

type Product {
  id: ID!
  name: String!
  price: Float
  category: String @deprecated
  sku: String
}

Lint output:

error  Description is required for type "Product"  @graphql-eslint/require-description
error  Description is required for field "id"      @graphql-eslint/require-description
error  Description is required for field "name"    @graphql-eslint/require-description
error  @deprecated requires a "reason" argument    @graphql-eslint/deprecation-reason

After — passes all rules, codegen emits full JSDoc:

"""
A product available for purchase in the catalog.
Prices are stored in the catalog currency; convert at the API gateway layer.
"""
type Product {
  """The globally unique product identifier. Stable across catalog imports."""
  id: ID!

  """Display name shown on product listing and detail pages. Maximum 200 characters."""
  name: String!

  """
  Price in the catalog's base currency (minor units, e.g. cents for USD).
  Null when the product is not yet priced (draft state).
  """
  price: Float

  """
  Legacy category string from the v1 catalog import.
  Null for products created after 2025-01-01, which use the structured `categories` field.
  """
  category: String @deprecated(reason: "Use the `categories` field instead. Removed after 2027-01-01.")

  """
  Stock-keeping unit code from the warehouse system.
  Null for digital products with no physical inventory.
  """
  sku: String
}

Every nullable field now carries an explicit null contract. The @deprecated includes a removal date. The type description provides context that is impossible to infer from field names alone.

Verification

After applying the annotations and updating CI, confirm the gates work end to end:

# 1. Lint passes with zero errors
npx eslint "**/*.graphql"
# Expected: no output, exit code 0

# 2. Codegen runs clean and produces no diff
npx graphql-codegen --check
# Expected: "All files are up to date" and exit code 0

# 3. Introspection confirms descriptions are present
npx @graphql-inspector/cli introspect http://localhost:4000/graphql \
  --write introspection.json
node -e "
  const s = require('./introspection.json');
  const types = s.__schema.types.filter(t => !t.name.startsWith('__'));
  const missing = types.filter(t => !t.description);
  if (missing.length) { console.error('Missing descriptions:', missing.map(t => t.name)); process.exit(1); }
  console.log('All types have descriptions.');
"
# Expected: "All types have descriptions." and exit code 0

# 4. Deliberately remove a description and confirm the lint fails
# (regression test for the CI gate itself)
sed -i 's/"""The globally unique product identifier.*"""//' schema.graphql
npx eslint "**/*.graphql" && echo "FAIL: gate did not catch missing description"
git checkout schema.graphql

Step 3 — introspecting the live server — verifies that the SDL on disk matches what the running server exposes. A code-first server that generates the schema from resolvers can silently drop descriptions if the framework does not preserve them; this check catches that case. For a deeper look at how schema evolution gates plug into Breaking Change Detection, see the breaking-change CI workflow that combines graphql-inspector diff with the lint gate in a single pipeline.

Edge Cases and Caveats

  • Code-first frameworks may discard descriptions. When the SDL is generated from resolver decorators (Nest.js @ObjectType, TypeGraphQL), the framework controls whether class or property JSDoc becomes the SDL description. In @nestjs/graphql 12, enable autoSchemaFile: true with buildSchemaOptions: { commentDescriptions: true } to preserve JSDoc. Verify after every framework upgrade — some minor versions have silently dropped description handling.

  • Stitched and federated schemas merge description nodes differently. In Apollo Federation 2, a shared type defined in multiple subgraphs can have conflicting descriptions. The gateway picks one non-deterministically. Resolve this by defining the description only in the subgraph that owns the type (using @key) and leaving descriptions empty in extending subgraphs.

  • Schema snapshots in tests become brittle. If your test suite asserts on the SDL string or on introspection output, adding a description is a diff. Update snapshot tests with --updateSnapshot as part of the same PR that adds the descriptions, and document in the PR description that the snapshot change is intentional documentation-only. Using graphql-inspector snapshot diffing rather than raw string equality prevents false-positive failures on description-only changes.

Frequently Asked Questions

What is the difference between a GraphQL SDL comment and a description string?

Hash comments (# ...) are stripped by the parser and do not appear in the AST or introspection. Triple-quoted strings ("""...""") are parsed as description nodes and surface in introspection, GraphiQL, and generated client code. Only triple-quoted strings populate the description field that tooling reads.

Can I add a description to an enum value in GraphQL SDL?

Yes. Place a triple-quoted string immediately before the enum value, just as you would before a type or field. The description appears in introspection results and generated TypeScript types when using graphql-codegen 5 with commentDescriptions: true.

Does adding a description to a field count as a breaking change?

No. Adding or changing a description is a non-breaking change under both graphql-inspector and the GraphQL spec. The AST description node is metadata only and does not affect query execution or the wire format.

How do I enforce description requirements only on public-facing types?

Write a custom graphql-eslint rule that checks for an @internal directive before requiring a description, or scope the require-description rule to specific type kinds (for example, only ObjectTypeDefinition and FieldDefinition) using the rule’s selector options.

Should I deprecate a field before or after adding its replacement?

Always add the replacement first, confirm clients can use it, then mark the original field with @deprecated(reason). Removing a field without a documented replacement in the reason string violates the additive-evolution contract and breaks any consumer that did not know about the migration path.