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.
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?
- Consistency: Enforces uniform API design across teams and projects.
- Early Error Detection: Finds mistakes before they hit production.
- Better Collaboration: Defines and communicates shared API standards.
- Automation-Ready: Integrates with CI/CD for continuous validation.
- Customizable: Supports custom rules tailored to your organization.
- Format-Agnostic: Can lint any JSON/YAML, not just OpenAPI.
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:
- Type Safety: Catch errors at build time, not runtime.
- Developer Productivity: Enjoy autocompletion and easier refactoring.
- Readable Code: Clear types make custom rules easier to maintain.
- Modern Syntax: Use the latest JavaScript features, compiled for compatibility.
- Testability: Strong typing simplifies automated tests for your rules.
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
- Node.js and npm (or yarn)
- A TypeScript project (or willingness to create one)
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
- @stoplight/spectral-core: Core linting engine
- @stoplight/spectral-functions: Built-in validation functions
- @stoplight/spectral-rulesets: Predefined rulesets (e.g., OpenAPI)
- typescript: TypeScript compiler
- ts-node: Run TypeScript files directly
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
- Rule: A check applied to your API spec (e.g., "all operation summaries must be title-cased").
- Ruleset: A file grouping multiple rules, can extend other rulesets.
- Function: The validation logic—either built-in (like
truthy,pattern) or custom.
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
- Asynchronous Validation: Custom functions can be
async(e.g., fetch external resources). - Accessing Full Document: Use
context.document.resolvedfor dereferenced schemas. - Working with $ref: Use
context.document.resolveAnchor(ref)to access referenced objects.
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
- Modularization: Split large rulesets into focused files; use
extendsto combine. - Shared Packages: Publish rulesets as npm packages for reuse across teams.
- functionsDir: For multiple custom JS functions, point
functionsDirto your compiled output.
Integrate Spectral Into Your Workflow
- CI/CD: Add
spectral lintto GitHub Actions, GitLab CI, or Jenkins.- name: Lint API Specification run: spectral lint ./path/to/api-spec.yaml --ruleset ./.spectral.js --fail-severity=error - Git Hooks: Use Husky to lint before commit or push.
- IDE Integration: Spectral plugins/extensions for editors like VS Code provide instant feedback.
Best Practices for TypeScript Spectral Rules
- Clear Descriptions: Write actionable descriptions and messages.
- Right Severity: Use
errorfor critical,warnfor important,infofor informational,hintfor suggestions. - Precise Targeting: Make JSONPath expressions as specific as possible.
- Pure Functions: Avoid side effects for predictable results.
- Unit Tests: Test your custom functions with different inputs.
- Performance: Watch out for slow rules on large specs.
- Strong Typing: Always type your function inputs and options.
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.



