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

20 January 2026

How to Build Powerful Custom Spectral Rules with TypeScript

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

Best Suno AI API Alternatives for Developers

Best Suno AI API Alternatives for Developers

Uncover the best Suno API alternatives for 2026, with KIE AI API leading as the top pick for seamless, multi-modal music creation. This guide compares features, benchmarks on latency and quality, and integration tips plus how Apidog streamlines API testing for flawless audio workflows.

20 January 2026

10 Best AI Video APIs for Developers 2026

10 Best AI Video APIs for Developers 2026

Compare top 10 AI video APIs for 2026 with real benchmarks on speed, cost, and quality. Includes Hypereal AI, OpenAI Sora, Google Veo, and integration guides.

20 January 2026

10 Best AI Image APIs for Developers

10 Best AI Image APIs for Developers

Explore the top 10 AI image APIs for 2026, ranked by performance, cost, and reliability. From Hypereal AI's lightning-fast generation to Flux Pro's quality-speed fusion, this guide delivers real benchmarks on latency, pricing, and error rates.

20 January 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs