If the core functions aren’t enough for your custom ruleset, Spectral allows you to write and use custom functions.
Start by creating a directory to contain your new functions. By default, Spectral looks for the functions/ folder.
functions/abc.js
export default input => { if (input !== "hello") { return [ { message: 'Value must equal "hello".', }, ]; }};The function is exported anonymously but it has the name abc from the file. This name is then loaded in your ruleset, via functions: [abc].
my-ruleset.yaml
functions: [abc]rules: my-rule: message: "{{error}}" given: "$.greeting.message" then: function: "abc"The function is looking for a targetVal of "hello" for anywhere it’s applied, and this rule uses a given target of $.greeting.
If the object being linted looks like this, everything is going to be ok.
greeting: message: helloIf the message was goodbye, Spectral would throw an exception.
Writing Functions
A custom function can be any JavaScript function compliant with RulesetFunction type.
export type RulesetFunction<I extends unknown = unknown, O extends unknown = unknown> = ( input: I, options: O, context: RulesetFunctionContext,) => void | IFunctionResult[] | Promise<void | IFunctionResult[]>;
export type RulesetFunctionContext = { path: JsonPath; document: IDocument; documentInventory: IDocumentInventory; rule: IRule;};Validating options
If you are writing a function that accepts options, you should provide a JSON Schema that describes those options.
You can do it as follows:
functions: - equalsrules: my-rule: message: "{{error}}" given: "$.info" then: function: "equals" functionOptions: value: "abc"Where the function functions/equals.js looks like:
import { createRulesetFunction } from "@openlint/openlint-core";
export default createRulesetFunction( { input: null, options: { type: "object", additionalProperties: false, properties: { value: true, }, required: ["value"], }, }, (targetVal, options) => { const { value } = options;
if (targetVal !== value) { return [ { message: `Value must equal ${value}.`, }, ]; } },);You can also name the custom function by using createRulesetFunction and passing a named function. This can help debug any errors as the function name is printed out in any error messages:
import { createRulesetFunction } from "@openlint/openlint-core";
export default createRulesetFunction( { input: null, options: { type: "object", additionalProperties: false, properties: { value: true, }, required: ["value"], }, }, function customEquals(targetVal, options) { const { value } = options;
if (targetVal !== value) { return [ { message: `Value must equal ${value}.`, }, ]; } },);input
input is the value the custom function is provided with and is supposed to lint against.
It’s based on given JSON Path expression defined on the rule and optionally field if placed on then.
For example, a rule can have given with a JSON Path expression of $, and the following partial OpenAPI document:
openapi: 3.0.0info: title: fooIn this example, targetValue would be a JavaScript object literal containing openapi and info properties. If you changed given to $.info.title, then targetValue would equal "foo".
options
options corresponds to functionOptions that’s defined in the then property of each rule.
Each rule can specify options that each function should receive. This can be done as follows:
operation-id-kebab-case: given: "$..operationId" then: function: pattern functionOptions: # this object is passed down as options to the custom function match: ^[a-z][a-z0-9\-]*$context
context.path contains a resolved property path pointing to a place in the document.
context.document provides access to the document that Spectral is attempting to lint. You may find it useful if you’d like to see which formats were applied to it, or in case you’d like to get its unresolved version.
context.documentInventory provides access to resolved and unresolved documents, the $ref resolution graph, as well as some other advanced properties. You shouldn’t need it most of the time.
context.rule is an actual rule your function was called for.
Custom functions take the same arguments as core functions do, so you are more than welcome to take a look at the existing implementation.
The process of creating a function involves 2 steps:
- Create a
.jsfile inside of a directory calledfunctionsthat should be placed next to your ruleset file - Create a
functionsarray in your ruleset and place a string using the function filename without the.jsextension
Returning Multiple Results
Many functions return a single message, but a function can return multiple messages.
For example, if a rule is created to make sure something is unique, it could either:
- Return a single error for the entire array which lists offending values in a comma-separated list
- Return a single error for the array value which contains the first offending non-unique item
- Return multiple errors for each duplicate value located
How exactly you chose to implement messages depends on the rule at hand, as well as personal preference.
my-ruleset.yaml
functions: [uniqueTagNames]rules: unique-tag-names: message: "Tags should have distinct names: {{error}}" given: "$.tags" then: function: "uniqueTagNames"functions/uniqueTagNames.js
import { isPlainObject } from "@stoplight/json";
const NAME_PROPERTY = "name";
export default createRulesetFunction( { input: { type: "array", items: { type: "object", properties: { name: { type: "string", }, }, }, }, options: null, }, (targetVal, options, { path }) => { const seen = []; const results = [];
for (const [i, value] of input.keys()) { if (targetVal[i] === null || typeof targetVal[i] !== "object") { continue; }
const tagName = input[i][NAME_PROPERTY];
if (tagName === void 0) { continue; }
if (seen.includes(tagName)) { results.push({ message: `Duplicate tag name '${tagName}'`, path: [...path, i, NAME_PROPERTY], }); } else { seen.push(tagName); } }
return results; },);Spectral attempts to deduplicate messages when they have the same code and target the same path. As such, if your custom function returns more than one result, you should specify a different path for each result.
Referencing Core Functions
Your custom function may also build on top of existing functions Spectral offers.
Make sure to provide all arguments that were originally passed to your function, otherwise, a core function may misbehave.
Example
import { truthy } from "@openlint/openlint-functions";
export default function (input, ...args) { if (input.info["skip-info"] === true) { // if info has a property with a key called "skip-info" and its value is true, let's do nothing return; }
// otherwise call the truthy function return truthy(input.info, ...args);}Async Functions
As of Spectral 5.4.0, custom functions can also be asynchronous.
Ideally linting should always be deterministic, which means if it’s run 10 times it should return the same results 10 times. To ensure this is the case, refrain from introducing any logic that’s prone to non-deterministic behavior. Examples of this might be contacting an external service you have no control over, or that’s unstable, or that changes the way it responds over time. While it may seem tempting to have a function that does so, the primary use case is to support libraries that makes
async fscalls or exchange information, such as obtaining a dictionary file, with a locally running server, etc.
functions/dictionary.js
const CACHE_KEY = "dictionary";
let dictionary;
export default async function (input) { if (!dictionary) { const res = await fetch("https://dictionary.com/evil"); if (res.ok) { dictionary = await res.json(); } else { // you can either re-try or just throw an error } }
if (dictionary.includes(input)) { return [{ message: `\`${input}\` is a forbidden word.` }]; }}my-ruleset.yaml
functions: - dictionaryrules: no-evil-words: message: "{{error}}" given: - "$.info.title" - "$.info.description" then: function: "dictionary"Changing Directory
Want to place your functions somewhere other than the functions/ directory? Use the functionsDir keyword in your ruleset.
functions: - abc# any path relative to the ruleset file is okayfunctionsDir: "./my-functions"rules: my-rule: message: "{{error}}" given: "$.info" then: function: "abc"Security Concerns
Keep in mind that for the time being, the code isn’t executed in a sandbox environment, so be careful when including external rulesets.
This indicates that almost any arbitrary code can be executed.
Potential risks include:
- Data/credentials infiltration
- Data tampering
- Running cpu-intensive tasks, such as crypto-mining
While the risk is relatively low, you should be careful about including external rulesets you aren’t in charge of, in particular those that use custom functions.
You are strongly encouraged to review the custom functions a given ruleset provides. What you should hunt for is:
- Obfuscated code
- Calls to an untrusted external library
- Places where remote code is executed
If you notice any weirdness, consider forking the ruleset and removing any evil-looking code.
Inheritance
Core functions can be overridden with custom rulesets, so if you’d like to make your own truthy function you can do so.
Custom functions are only available in the ruleset which defines them, so loading a foo function in one ruleset isn’t going to affect a foo function in another ruleset.
Performance Tips
Try to avoid allocating objects if your custom function is generic, and therefore is expected to be used by plenty of rules.
If your document is big, and the JSON path expression is loose (meaning it matches a lot of properties), your function could be called hundreds of thousands of times.
// badexport default (targetVal, { excludedWords }) => { const results = []; // the array is always allocated, even if targetVal is perfectly valid
if (excludedWords.includes("foo")) { results.push({ error: "Forbidden word used" }); }
return results;};// better, no temporary array if targetVal is validexport default (targetVal, { excludedWords }) => { if (excludedWords.includes("foo")) { return [{ error: "Forbidden word used" }]; }};Supporting Multiple Environments
Spectral is meant to support a variety of environments, so ideally your function should behave similarly in Node.js and browser contexts. Don’t rely on globals or functions specific to a particular environment. For example, don’t expect the browser window global to always be available, since this global isn’t available in Node.js environments.
If you need to access environment-specific APIs, make sure you provide an alternative for other environments. A good example of such a situation is fetch - a function available natively in a browser context, but missing in Node.js.
To keep your code cross-platform, you’d need to use a cross-platform package such as node-fetch or isomorphic-fetch, both of which implement spec-compliant fetch API and work in Node.js.
Code Transpilation
We encourage you to not transpile the code to ES5 if you can help it. Spectral doesn’t support older environments than ES2019, so there is no need to bloat the bundle with useless transformations and polyfills. Ship untransformed async/await, don’t include unneeded shims, it’s all good.
Before 6.x, Spectral hadn’t supported ES Modules, yet as of recently using ES Modules is the recommended way to do things.
Last updated Oct 24, 2026