Type‑Safe Custom ESLint Rules with TypeScript & Zod: Build, Test, and Enforce Domain‑Specific Standards
Introduction
Static analysis tools are the first line of defense against accidental architectural drift. ESLint already gives you a rich ecosystem of rules, but most teams eventually hit a wall: the built‑in rules don’t speak the language of your domain.
What if you could write your own lint rules in TypeScript, guarantee the shape of rule options with Zod, and have a solid testing harness that treats the rule like any other piece of production code?
In this article we’ll walk through:
- Setting up a minimal ESLint plugin project that compiles with ts‑up (or
esbuild). - Defining a type‑safe options schema with Zod and exposing it to users.
- Implementing a rule that enforces a domain‑specific convention – “all public API functions must be documented with a
@publicApiJSDoc tag”. - Writing unit tests with @eslint/eslintrc and eslint‑rule‑tester that verify both the AST logic and the runtime validation.
- Publishing the rule as a reusable npm package (optional, brief notes).
By the end you’ll have a ready‑to‑use custom rule that refuses to compile if its configuration is malformed, and you’ll see how Zod bridges the gap between TypeScript’s static types and ESLint’s runtime environment.
1. Project scaffolding
mkdir eslint-plugin-domain
cd eslint-plugin-domain
npm init -y
npm i -D typescript @types/node eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
eslint-rule-tester @eslint/eslintrc zod esbuild
Create a tsconfig.json that targets ES2022 (required for modern syntax) and emits CommonJS for ESLint compatibility:
{
"compilerOptions": {
"module": "commonjs",
"target": "es2022",
"declaration": true,
"outDir": "dist",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
Add a tiny build script that uses esbuild (fast, zero‑config) to bundle the plugin:
// package.json scripts
"build": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.js --sourcemap",
"test": "node test/run.js"
Now create the folder layout:
src/
rules/
require-public-api-tag.ts
schema/
requirePublicApiTag.ts
index.ts
test/
require-public-api-tag.test.ts
run.js
2. Defining a Zod schema for rule options
ESLint loads rule options from a plain JSON object, so at runtime we must validate that shape. Zod gives us a declarative schema that also generates a TypeScript type, guaranteeing the two stay in sync.
// src/schema/requirePublicApiTag.ts
import { z } from "zod";
export const RequirePublicApiTagOptionsSchema = z.object({
/** Tag that marks a function as part of the public API. */
tag: z.string().default("@publicApi"),
/** Optional list of file globs where the rule is ignored. */
ignorePatterns: z.array(z.string()).optional(),
});
export type RequirePublicApiTagOptions = z.infer<
typeof RequirePublicApiTagOptionsSchema
>;
The default clause lets users omit the tag option – the rule will still work with the conventional @publicApi. Because the schema lives in src/schema, any future rule can simply import and extend it.
3. Implementing the rule
The rule walks the AST, looks for exported function declarations, and checks for a JSDoc comment containing the configured tag.
// src/rules/require-public-api-tag.ts
import { TSESTree, ESLintUtils } from "@typescript-eslint/utils";
import {
RequirePublicApiTagOptions,
RequirePublicApiTagOptionsSchema,
} from "../schema/requirePublicApiTag";
type MessageIds = "missingTag";
export const rule = ESLintUtils.RuleCreator(
(name) => `https://example.com/rules/${name}`
)<RequirePublicApiTagOptions, MessageIds>({
name: "require-public-api-tag",
meta: {
type: "problem",
docs: {
description:
"Ensures exported functions that belong to the public API have a specific JSDoc tag.",
recommended: "error",
},
schema: [
// The Zod schema is transformed into the JSON schema ESLint expects.
// ESLintUtils provides a helper for that.
RequirePublicApiTagOptionsSchema.toJSON(),
],
messages: {
missingTag:
"Exported function '{{name}}' must be documented with '{{tag}}' tag.",
},
},
defaultOptions: [{}],
create(context, [rawOptions]) {
// Runtime validation – throws a clear error if the user mis‑configures.
const options = RequirePublicApiTagOptionsSchema.parse(rawOptions);
return {
// Handles both function declarations and arrow functions assigned to a const.
"ExportNamedDeclaration > FunctionDeclaration"(node: TSESTree.FunctionDeclaration) {
if (!node.id) return; // anonymous export – ignore
validateFunction(node, context, options);
},
"ExportNamedDeclaration > VariableDeclaration > VariableDeclarator"(node: TSESTree.VariableDeclarator) {
// const foo = () => {}
if (
node.init &&
(node.init.type === "ArrowFunctionExpression" ||
node.init.type === "FunctionExpression")
) {
validateFunction(node, context, options);
}
},
};
},
});
function validateFunction(
node: TSESTree.Node,
context: any,
options: RequirePublicApiTagOptions
) {
const source = context.getSourceCode();
const jsDoc = source.getJSDocComment(node);
const hasTag =
jsDoc?.value?.includes(options.tag) ?? false;
if (!hasTag) {
const name =
"id" in node && node.id?.name
? node.id.name
: "key" in node && (node as any).key?.name
? (node as any).key.name
: "unknown";
context.report({
node,
messageId: "missingTag",
data: { name, tag: options.tag },
});
}
}
Why the extra type safety matters
RequirePublicApiTagOptionsis inferred directly from the Zod schema, so if you later add a new option (severity?: "warn" | "error"), the rule’screatesignature automatically reflects it.- The call to
RequirePublicApiTagOptionsSchema.parseguarantees runtime safety – ESLint will crash with a helpful message rather than silently ignore a typo in the config.
4. Exporting the plugin entry point
// src/index.ts
import { ESLint } from "eslint";
import { rule as requirePublicApiTag } from "./rules/require-public-api-tag";
export const rules = {
"require-public-api-tag": requirePublicApiTag,
};
export const configs = {
recommended: {
plugins: ["domain"],
rules: {
"domain/require-public-api-tag": "error",
},
},
};
export default {
rules,
configs,
};
When published, the package name (e.g., eslint-plugin-domain) tells ESLint to resolve domain as the plugin identifier.
5. Testing the rule
ESLint provides RuleTester, but it expects plain JSON options. We’ll combine it with @eslint/eslintrc to feed a typed configuration.
// test/require-public-api-tag.test.ts
import { RuleTester } from "eslint";
import { rule } from "../src/rules/require-public-api-tag";
import { Linter } from "eslint";
const tester = new RuleTester({
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
});
tester.run("require-public-api-tag", rule, {
valid: [
{
code: `
/** @publicApi */
export function getUser(id: string) { /* … */ }
`,
},
{
code: `
/** @publicApi */
export const createOrder = (data) => { /* … */ };
`,
options: [{ tag: "@publicApi" }],
},
],
invalid: [
{
code: `
export function deleteUser(id: string) {}
`,
errors: [{ messageId: "missingTag", data: { name: "deleteUser", tag: "@publicApi" } }],
},
{
code: `
/** Not a public API */
export const internal = () => {};
`,
options: [{ tag: "@publicApi" }],
errors: [{ messageId: "missingTag", data: { name: "internal", tag: "@publicApi" } }],
},
],
});
Run the test with the script defined earlier:
npm run test
If you prefer a watch mode, replace node test/run.js with a small ts-node runner.
Testing the Zod validation itself
A separate unit test can ensure the schema rejects bad config:
// test/schema.test.ts
import { RequirePublicApiTagOptionsSchema } from "../src/schema/requirePublicApiTag";
describe("RequirePublicApiTagOptionsSchema", () => {
it("accepts defaults", () => {
expect(() => RequirePublicApiTagOptionsSchema.parse({})).not.toThrow();
});
it("fails on unknown keys", () => {
expect(() =>
RequirePublicApiTagOptionsSchema.parse({ unknown: true } as any)
).toThrow();
});
});
Running these alongside the rule tests gives you full confidence that both static (type) and dynamic (runtime) contracts are honoured.
6. Using the rule in a real project
Add the plugin as a dev dependency:
npm i -D eslint-plugin-domain
Then extend your ESLint config:
// .eslintrc.json
{
"extends": ["plugin:domain/recommended"],
"plugins": ["domain"],
"rules": {
"domain/require-public-api-tag": ["error", { "tag": "@publicApi" }]
}
}
Now any file that exports a function without the proper JSDoc tag will be flagged during npm run lint. Because the rule runs as part of the normal ESLint pipeline, you can also integrate it into CI/CD, pre‑commit hooks, or VS Code’s built‑in diagnostics.
7. Publishing (quick notes)
If you decide to share the rule with other teams:
- Add
"main": "dist/index.js", "types": "dist/index.d.ts"topackage.json. - Run
npm run build && npm publish. - Encourage consumers to pin the version (
^1.0.0) and add the plugin to their ESLint configs as shown above.
Because the package ships type declarations generated by the TypeScript compiler, IDEs will autocomplete the rule name and its options, further reducing friction.
8. Takeaways
| ✅ What we achieved | 📚 How we did it |
|---|---|
| Domain‑specific lint rule that checks JSDoc tags | Implemented a visitor for ExportNamedDeclaration nodes |
| Compile‑time type safety for rule options | Defined a Zod schema and derived a TypeScript type |
| Runtime validation with clear error messages | schema.parse inside the rule’s create function |
| Automated testing covering both AST logic and config validation | Used RuleTester + Jest (or Vitest) for rule tests; separate Zod unit tests |
| Zero‑config build that outputs a ready‑to‑publish CommonJS bundle | Leveraged esbuild for fast compilation |
Custom ESLint rules often feel like a “one‑off” hack, but when you combine TypeScript and Zod, they become first‑class, maintainable pieces of your codebase. The pattern shown here scales: you can create a library of domain rules, share them across micro‑services, and let the type system guard you against both accidental mis‑configuration and future refactors.
Happy linting!
Member discussion