I used Spectral with TypeScript. Here’s how I made it sing.

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);