How to Build Powerful Custom Spectral Rules with TypeScript

Learn how to enforce API standards with custom Spectral rules in TypeScript. This practical guide covers setup, examples, and best practices for scalable, automated API linting—plus how Apidog can streamline your API workflow.

Audrey Lopez

Audrey Lopez

31 January 2026

How to Build Powerful Custom Spectral Rules with TypeScript

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

API consistency and quality are critical for developer teams working with OpenAPI or AsyncAPI specifications. Yet, enforcing these standards across multiple projects can be difficult. This is where API linting tools like Spectral come in—helping you catch errors early and maintain high-quality, standardized API contracts.

In this guide, you’ll learn how to supercharge Spectral by developing custom linting rules with TypeScript. We’ll walk through setup, practical examples, and advanced techniques so you can automate API governance at scale.

💡 Looking to streamline your API lifecycle? Apidog offers beautiful API documentation, team collaboration, and a unified testing platform—all at a fraction of the cost of Postman. Boost your team’s productivity and see why leading teams switch to Apidog.

button

What Is Spectral? API Linting for OpenAPI & AsyncAPI

Spectral is an open-source linter for JSON and YAML, designed specifically for API description formats like OpenAPI (v2/v3) and AsyncAPI. Think of it as the “ESLint” for your API specs—enforcing design guidelines, catching common mistakes, and ensuring documentation consistency.

Why Use Spectral?

Spectral uses rulesets—collections of rules targeting specific parts of your API via JSONPath. You can use built-in rules (like spectral:oas), but its real power comes from writing your own.


Why Write Custom Spectral Rules in TypeScript?

While Spectral supports YAML and JavaScript rule definitions, TypeScript brings big advantages:

By writing custom Spectral functions in TypeScript, you ensure your API governance code is as robust as your application code.


Setting Up Spectral and TypeScript

Prerequisites

Install Required Packages

Install the Spectral CLI globally to run lint operations:

npm install -g @stoplight/spectral-cli
# or
yarn global add @stoplight/spectral-cli

In your project, add Spectral and TypeScript as dev dependencies:

npm install @stoplight/spectral-core @stoplight/spectral-functions @stoplight/spectral-rulesets typescript ts-node --save-dev
# or
yarn add @stoplight/spectral-core @stoplight/spectral-functions @stoplight/spectral-rulesets typescript ts-node --dev

Basic TypeScript Configuration

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

Understanding Rules, Rulesets, and Functions in Spectral

Custom functions allow you to enforce organization-specific API standards.


Example: Write a Custom Spectral Rule in TypeScript

Let’s build a custom rule: “Operation summaries must be title-cased and under 70 characters.”

1. Create a Custom Function

Create src/customFunctions.ts:

import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';

interface TitleCaseLengthOptions {
  maxLength: number;
}

export const titleCaseAndLength: IFunction<string, TitleCaseLengthOptions> = (
  targetVal,
  options,
  context
): IFunctionResult[] | void => {
  const results: IFunctionResult[] = [];

  if (typeof targetVal !== 'string') {
    return [{ message: `Value at path '${context.path.join('.')}' must be a string.` }];
  }

  const words = targetVal.split(' ');
  const isTitleCase = words.every(word =>
    word.length === 0 ||
    (word[0] === word[0].toUpperCase() &&
      (word.length === 1 || word.substring(1) === word.substring(1).toLowerCase()))
  );

  if (!isTitleCase) {
    results.push({
      message: `Summary "${targetVal}" at path '${context.path.join('.')}' must be in Title Case.`,
      path: [...context.path],
    });
  }

  const maxLength = options?.maxLength || 70;
  if (targetVal.length > maxLength) {
    results.push({
      message: `Summary "${targetVal}" at path '${context.path.join('.')}' exceeds maximum length of ${maxLength} characters. Current length: ${targetVal.length}.`,
      path: [...context.path],
    });
  }

  return results;
};

2. Compile the Function

Add to package.json:

"scripts": {
  "build": "tsc"
}

Run:

npm run build

This compiles your TypeScript to dist/customFunctions.js.

3. Define Your Ruleset

Create .spectral.js:

const { titleCaseAndLength } = require('./dist/customFunctions');

module.exports = {
  extends: [['@stoplight/spectral-rulesets/dist/rulesets/oas', 'recommended']],
  rules: {
    'operation-summary-title-case-length': {
      description: 'Operation summaries must be title-cased and not exceed 70 characters.',
      message: '{{error}}',
      given: '$.paths[*][*].summary',
      severity: 'warn',
      formats: ["oas3"],
      then: {
        function: titleCaseAndLength,
        functionOptions: {
          maxLength: 70,
        },
      },
    },
  },
};

Alternatively, use .spectral.yaml and functionsDir:

extends:
  - ["@stoplight/spectral-rulesets/dist/rulesets/oas", "recommended"]
functionsDir: "./dist"
rules:
  operation-summary-title-case-length:
    description: "Operation summaries must be title-cased and not exceed 70 characters."
    message: "{{error}}"
    given: "$.paths[*][*].summary"
    severity: "warn"
    formats: ["oas3"]
    then:
      function: customFunctions#titleCaseAndLength
      functionOptions:
        maxLength: 70

4. Test With a Sample OpenAPI Document

openapi.yaml example:

openapi: 3.0.0
info:
  title: Sample API
  version: 1.0.0
paths:
  /items:
    get:
      summary: retrieves all items from the store
      responses:
        '200':
          description: A list of items.
    post:
      summary: Adds A New Item To The Ever Expanding Collection Of Items In The Store
      responses:
        '201':
          description: Item created.
  /users:
    get:
      summary: Get User Details
      responses:
        '200':
          description: User details.

5. Run the Linter

spectral lint openapi.yaml --ruleset .spectral.js

Expected Output:

openapi.yaml
 2:10  warning  operation-summary-title-case-length  Summary "retrieves all items from the store" at path 'paths./items.get.summary' must be in Title Case.   paths./items.get.summary
 6:10  warning  operation-summary-title-case-length  Summary "Adds A New Item To The Ever Expanding Collection Of Items In The Store" at path 'paths./items.post.summary' exceeds maximum length of 70 characters. Current length: 78.  paths./items.post.summary

✖ 2 problems (0 errors, 2 warnings, 0 infos, 0 hints)

Advanced: Programmatic Spectral Usage with TypeScript

For complex automation, define your rulesets and linter logic in TypeScript directly.

Example src/linter.ts:

import { Spectral, Document } from '@stoplight/spectral-core';
import { oas } from '@stoplight/spectral-rulesets';
import { truthy } from '@stoplight/spectral-functions';
import { titleCaseAndLength } from './customFunctions';
import type { ISpectralDiagnostic } from '@stoplight/spectral-core';
import * as fs from 'fs/promises';
import * as path from 'path';

const spectral = new Spectral();

spectral.setRuleset({
  extends: [[oas, 'recommended']],
  rules: {
    'operation-summary-title-case-length': {
      description: 'Operation summaries must be title-cased and not exceed 70 characters.',
      message: '{{error}}',
      given: '$.paths[*][*].summary',
      severity: 'warn',
      formats: ["oas3"],
      then: {
        function: titleCaseAndLength,
        functionOptions: {
          maxLength: 70,
        },
      },
    },
    'info-contact-defined': {
      description: 'Info object should have a contact object.',
      message: 'API contact information is missing.',
      given: '$.info',
      severity: 'warn',
      formats: ["oas3"],
      then: {
        field: 'contact',
        function: truthy,
      },
    },
  },
});

export async function lintDocument(filePath: string): Promise<ISpectralDiagnostic[]> {
  try {
    const absolutePath = path.resolve(filePath);
    const fileContent = await fs.readFile(absolutePath, 'utf-8');
    const document = new Document(fileContent, undefined, absolutePath);
    const results = await spectral.run(document);
    return results;
  } catch (error) {
    console.error('Error linting document:', error);
    return [];
  }
}

async function main() {
  const diagnostics = await lintDocument('openapi.yaml');
  if (diagnostics.length > 0) {
    console.log('API Linting Issues Found:');
    diagnostics.forEach(issue => {
      console.log(
        `- [${issue.severity === 0 ? 'Error' : issue.severity === 1 ? 'Warning' : issue.severity === 2 ? 'Info' : 'Hint'}] ${issue.code} (${issue.message}) at ${issue.path.join('.')}`
      );
    });
  } else {
    console.log('No API linting issues found. Great job!');
  }
}

if (require.main === module) {
  main().catch(console.error);
}

Run with:

npx ts-node src/linter.ts

Advanced Custom Function Patterns

Example: Enforce a referenced schema contains a property

import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
import { isPlainObject } from '@stoplight/json';

export const referencedSchemaHasProperty: IFunction<{ $ref: string }, { propertyName: string }> = (
  targetVal,
  options,
  context
): IFunctionResult[] | void => {
  if (!targetVal.$ref) return;

  const resolvedValue = context.document.resolveAnchor(targetVal.$ref);

  if (!resolvedValue || !isPlainObject(resolvedValue.value)) {
    return [{ message: `Could not resolve $ref: ${targetVal.$ref}` }];
  }

  const schemaProperties = resolvedValue.value.properties as Record<string, unknown> | undefined;

  if (!schemaProperties || schemaProperties[options.propertyName] === undefined) {
    return [{
      message: `The schema referenced by "${targetVal.$ref}" at path '${context.path.join('.')}' must have property "${options.propertyName}".`,
      path: [...context.path, '$ref']
    }];
  }
};

Organizing and Sharing Rulesets


Integrate Spectral Into Your Workflow


Best Practices for TypeScript Spectral Rules


Conclusion: Scale API Quality with Spectral & TypeScript

Spectral offers a flexible, powerful way to enforce API standards. By leveraging TypeScript for custom rule development, you maximize type safety, maintainability, and developer experience. Automating API linting—from local development to CI/CD—ensures your APIs remain consistent, reliable, and easy to consume.

Looking for a platform to take your API workflow even further? Apidog helps you generate documentation, test APIs, and collaborate with your team—all in one place.

button

Explore more

Kimi K3 vs Kimi K2.7 Code: What Actually Changed

Kimi K3 vs Kimi K2.7 Code: What Actually Changed

Kimi K3 vs Kimi K2.7 Code compared: the jump in scale, the new attention architecture, 1M context, pricing shifts, and a clear migrate-or-stay decision guide.

17 July 2026

An Open Model Just Beat Claude Opus 4.8 on Every Benchmark Moonshot Published

An Open Model Just Beat Claude Opus 4.8 on Every Benchmark Moonshot Published

Kimi K3 beat Claude Opus 4.8 on all five of Moonshot's launch benchmarks, at a fraction of the cost, with open weights due July 27. Here's what it means.

17 July 2026

Kimi K3 vs GPT-5.6 Sol: Open Weights Meets the Frontier

Kimi K3 vs GPT-5.6 Sol: Open Weights Meets the Frontier

Kimi K3 vs GPT-5.6 Sol compared: Sol leads on top-end quality, K3 wins on open weights, 1M context, and price. Full comparison table and decision matrix.

17 July 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs

How to Build Powerful Custom Spectral Rules with TypeScript