Hi, I’m Kayla. I lint OpenAPI files a lot. I used Spectral with TypeScript on a real API at work last month. It saved me from silly errors. It also yelled at me when I deserved it. Fair.
If you want the extended back-story, check out my companion write-up — I used Spectral with TypeScript—here’s how I made it sing.
Let me explain how I set it up, what code I wrote, and what tripped me up. I’ll keep it plain. Real files. Real fixes.
Wait, what’s Spectral?
It’s a linter for JSON and YAML. It shines with OpenAPI. You add rules. It checks your files. It points to the line. It says what’s wrong. Nice and clear. It’s open-source too—the engine lives in the Spectral GitHub repository if you ever want to peek under the hood.
I used it two ways:
- with the CLI (quick wins)
- with TypeScript code (more control)
I like both. I switch based on the task. You know what? That’s the fun part.
If you're curious about sharpening your TypeScript tooling even further, I share additional tricks over on Improving Code.
The quick way: CLI + a tiny ruleset
First, I installed the CLI in my repo.
npm i -D @stoplight/spectral-cli @stoplight/spectral-rulesets
Then I made a .spectral.yaml at the root:
extends: "spectral:oas"
rules:
# Turn one rule off (my team did not want it)
operation-summary: off
# Make contact info a gentle nudge
info-contact: warn
# Custom: no trailing slash in paths like /pets/
path-no-trailing-slash:
description: Paths should not end with a slash
message: Remove the trailing slash
recommended: true
type: style
given: $.paths[*]~key
then:
function: pattern
functionOptions:
notMatch: "/$"
Then I ran it:
npx spectral lint openapi.yaml
I also added a script:
{
"scripts": {
"lint:api": "spectral lint openapi.yaml"
}
}
For a deeper rundown of every CLI flag and step-by-step examples of custom rules, the official Spectral documentation has you covered.
That caught a bad path key in seconds. Low stress win.
The fun way: Programmatic use in TypeScript
I wanted a custom check and a clean JSON output for CI. So I used the core API.
Install the bits:
npm i -D @stoplight/spectral-core @stoplight/spectral-rulesets @stoplight/spectral-parsers
My tsconfig.json was simple:
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
Now the TypeScript file. This lints one file, adds one custom rule, and prints results.
// src/lintApi.ts
import { readFile } from 'node:fs/promises';
import { Spectral, Document, RulesetDefinition } from '@stoplight/spectral-core';
import { Yaml } from '@stoplight/spectral-parsers';
import { oas } from '@stoplight/spectral-rulesets';
async function lint(filePath: string) {
const spectral = new Spectral();
Spectral’s constructor is blissfully empty, but for classes that need many values, I've experimented with the pattern of named arguments and weighed the trade-offs here: I tried TypeScript named arguments for constructors—here’s my take.
// Merge built-in OAS rules with one custom rule
const ruleset: RulesetDefinition = {
extends: [oas],
rules: {
'no-trailing-slash': {
description: 'No trailing slash in path keys',
message: 'Remove the trailing slash at the end',
type: 'style',
severity: 'warn',
given: '$.paths[*]~key',
then: {
function: 'pattern',
functionOptions: { notMatch: '/$' }
}
}
}
};
The filePath: string argument above may look harmless, but if you accidentally hand it a directory or a malformed path, chaos follows. I share my guard-rails pattern in TypeScript file path argument—my hands-on take.
await spectral.setRuleset(ruleset);
const raw = await readFile(filePath, 'utf8');
const doc = new Document(raw, Yaml, filePath);
const results = await spectral.run(doc);
// Sort for steady output in CI
results.sort((a, b) => (a.path.join('.') > b.path.join('.')) ? 1 : -1);
console.log(JSON.stringify(results, null, 2));
// non-zero exit if there are errors
const hasError = results.some(r => r.severity === 0 || r.severity === 1); // 0=error, 1=warn in some setups
if (hasError) {
process.exitCode = 1;
}
}
lint(process.argv[2] ?? 'openapi.yaml').catch(err => {
console.error(err);
process.exitCode = 1;
});
Notice the process.argv[2] ?? 'openapi.yaml' pattern; it lets callers omit an argument entirely. If optional parameters still feel fuzzy, my deep-dive is here: TypeScript optional parameter—my hands-on take.
Build and run:
npx tsc
node dist/lintApi.js openapi.yaml
It flagged /pets/ for me. I fixed the key to /pets. Clean run. Sweet.
A tiny custom function in TypeScript
I wanted tag names in kebab-case. So I wrote a small function.
Install one more piece if you want to write functions:
npm i -D @stoplight/spectral-functions
Now the function file:
// src/functions/kebabCase.ts
import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
export const kebabCase: IFunction = (targetVal): IFunctionResult[] => {
const out: IFunctionResult[] = [];
if (typeof targetVal !== 'string') return out;
const ok = /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(targetVal);
if (!ok) {
out.push({
message: 'Use kebab-case (like this-example)'
});
}
return out;
};
Wire it into the ruleset:
// src/lintKebab.ts
import { readFile } from 'node:fs/promises';
import { Spectral, Document, RulesetDefinition } from '@stoplight/spectral-core';
import { Yaml } from '@stoplight/spectral-parsers';
import { oas } from '@stoplight/spectral-rulesets';
import { kebabCase } from './functions/kebabCase';
async function lint(filePath: string) {
const spectral = new Spectral();
const ruleset: RulesetDefinition = {
extends: [oas],
functions: { kebabCase },
rules: {
'tag-kebab-case': {
description: 'Tags must be kebab-case',
given: '$.tags[*].name',
severity: 'warn',
then: { function: 'kebabCase' }
}
}
};
await spectral.setRuleset(ruleset);
const raw = await readFile(filePath, 'utf8');
const doc = new Document(raw, Yaml, filePath);
const results = await spectral.run(doc);
console.log(JSON.stringify(results, null, 2));
}
lint(process.argv[2] ?? 'openapi.yaml').catch(console.error);
When a tag was PetStore, it warned me. I changed it to pet-store. Done.
One more: lint a string in memory
Sometimes I get the file in a test. No disk. This works:
“`ts
import { Spectral, Document } from '@stoplight/spectral-core';
import { Yaml } from '@stoplight/spectral-parsers';
import { oas } from '@stoplight/spectral-rulesets';
export async function lintFromString(name: string, yaml: string) {
const spectral = new Spectral();
await spectral.setRuleset(oas);
const doc = new Document(yaml, Yaml, name);
return spectral.run(doc);
