├── .README ├── README.md └── rules │ ├── prefer-enum.md │ └── require-strict.md ├── .eslintrc ├── .github └── workflows │ ├── feature.yaml │ └── main.yaml ├── .gitignore ├── .npmignore ├── .releaserc ├── LICENSE ├── README.md ├── bin └── readmeAssertions.ts ├── package-lock.json ├── package.json ├── src ├── index.ts └── rules │ ├── preferEnum.ts │ └── requireStrict.ts ├── test └── rules │ ├── assertions │ ├── preferEnum.ts │ └── requireStrict.ts │ └── index.ts └── tsconfig.json /.README/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-zod 2 | 3 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 4 | [![NPM version](http://img.shields.io/npm/v/eslint-plugin-zod.svg?style=flat-square)](https://www.npmjs.org/package/eslint-plugin-zod) 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 6 | 7 | [Zod](https://github.com/colinhacks/zod) linting rules for ESLint. 8 | 9 | {"gitdown": "contents"} 10 | 11 | ## Installation 12 | 13 | 1. Install [ESLint](https://www.github.com/eslint/eslint). 14 | 1. Install [`eslint-plugin-zod`](https://github.com/gajus/eslint-plugin-zod) plugin. 15 | 16 | 17 | 18 | ```sh 19 | npm install eslint --save-dev 20 | npm install eslint-plugin-zod --save-dev 21 | ``` 22 | 23 | ## Configuration 24 | 25 | 1. Add `plugins` section and specify `eslint-plugin-zod` as a plugin. 26 | 1. Enable rules. 27 | 28 | 29 | 30 | ```json 31 | { 32 | "plugins": [ 33 | "zod" 34 | ], 35 | "rules": { 36 | "zod/prefer-enum": 2, 37 | "zod/require-strict": 2 38 | } 39 | } 40 | 41 | ``` 42 | 43 | ## Rules 44 | 45 | 46 | 47 | {"gitdown": "include", "file": "./rules/prefer-enum.md"} 48 | {"gitdown": "include", "file": "./rules/require-strict.md"} 49 | -------------------------------------------------------------------------------- /.README/rules/prefer-enum.md: -------------------------------------------------------------------------------- 1 | ### `prefer-enum` 2 | 3 | _The `--fix` option on the command line automatically fixes problems reported by this rule._ 4 | 5 | Prefers `z.enum` over a union of literals. 6 | 7 | 8 | -------------------------------------------------------------------------------- /.README/rules/require-strict.md: -------------------------------------------------------------------------------- 1 | ### `require-strict` 2 | 3 | _The `--fix` option on the command line automatically fixes problems reported by this rule._ 4 | 5 | Requires that objects are initialized with `.strict()`. 6 | 7 | #### Options 8 | 9 | |configuration|format|default|description| 10 | |---|---|---|---| 11 | |`allowPassthrough`|boolean|`true`|Ignores objects explicitly set to `allowPassthrough()`.| 12 | 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "canonical", 4 | "canonical/typescript" 5 | ], 6 | "parserOptions": { 7 | "project": "./tsconfig.json" 8 | }, 9 | "rules": { 10 | "no-template-curly-in-string": 0 11 | }, 12 | "root": true 13 | } -------------------------------------------------------------------------------- /.github/workflows/feature.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | environment: release 5 | name: Test 6 | steps: 7 | - name: setup repository 8 | uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: setup node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: "18" 15 | - run: npm install 16 | - run: npm run lint 17 | - run: npm run test 18 | - run: npm run build 19 | timeout-minutes: 10 20 | name: Test and build 21 | on: 22 | pull_request: 23 | branches: 24 | - main 25 | types: 26 | - opened 27 | - synchronize 28 | - reopened 29 | - ready_for_review 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | environment: release 5 | name: Test 6 | steps: 7 | - name: setup repository 8 | uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: setup node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: "18" 15 | - run: npm install 16 | - run: npm run lint 17 | - run: npm run test 18 | - run: npm run build 19 | - run: npx semantic-release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | name: Test, build and release 24 | on: 25 | push: 26 | branches: 27 | - main 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | *.log 5 | .* 6 | !.babelrc 7 | !.eslintrc 8 | !.github 9 | !.gitignore 10 | !.npmignore 11 | !.README 12 | !.releaserc -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | bin 3 | !/dist/src 4 | test 5 | coverage 6 | *.tgz 7 | .* 8 | *.log 9 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/github", 8 | "@semantic-release/npm" 9 | ] 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Gajus Kuizinas (https://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (https://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL GAJUS KUIZINAS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # eslint-plugin-zod 4 | 5 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 6 | [![NPM version](http://img.shields.io/npm/v/eslint-plugin-zod.svg?style=flat-square)](https://www.npmjs.org/package/eslint-plugin-zod) 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 8 | 9 | [Zod](https://github.com/colinhacks/zod) linting rules for ESLint. 10 | 11 | * [eslint-plugin-zod](#user-content-eslint-plugin-zod) 12 | * [Installation](#user-content-eslint-plugin-zod-installation) 13 | * [Configuration](#user-content-eslint-plugin-zod-configuration) 14 | * [Rules](#user-content-eslint-plugin-zod-rules) 15 | * [`prefer-enum`](#user-content-eslint-plugin-zod-rules-prefer-enum) 16 | * [`require-strict`](#user-content-eslint-plugin-zod-rules-require-strict) 17 | 18 | 19 | 20 | 21 | ## Installation 22 | 23 | 1. Install [ESLint](https://www.github.com/eslint/eslint). 24 | 1. Install [`eslint-plugin-zod`](https://github.com/gajus/eslint-plugin-zod) plugin. 25 | 26 | 27 | 28 | ```sh 29 | npm install eslint --save-dev 30 | npm install eslint-plugin-zod --save-dev 31 | ``` 32 | 33 | 34 | 35 | ## Configuration 36 | 37 | 1. Add `plugins` section and specify `eslint-plugin-zod` as a plugin. 38 | 1. Enable rules. 39 | 40 | 41 | 42 | ```json 43 | { 44 | "plugins": [ 45 | "zod" 46 | ], 47 | "rules": { 48 | "zod/prefer-enum": 2, 49 | "zod/require-strict": 2 50 | } 51 | } 52 | 53 | ``` 54 | 55 | 56 | 57 | ## Rules 58 | 59 | 60 | 61 | 62 | 63 | ### prefer-enum 64 | 65 | _The `--fix` option on the command line automatically fixes problems reported by this rule._ 66 | 67 | Prefers `z.enum` over a union of literals. 68 | 69 | 70 | 71 | 72 | 73 | ### require-strict 74 | 75 | _The `--fix` option on the command line automatically fixes problems reported by this rule._ 76 | 77 | Requires that objects are initialized with `.strict()`. 78 | 79 | 80 | 81 | #### Options 82 | 83 | |configuration|format|default|description| 84 | |---|---|---|---| 85 | |`allowPassthrough`|boolean|`true`|Ignores objects explicitly set to `allowPassthrough()`.| 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /bin/readmeAssertions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable @typescript-eslint/no-require-imports */ 3 | 4 | /** 5 | * @file This script is used to inline assertions into the README.md documents. 6 | */ 7 | 8 | import fs from 'fs'; 9 | import path from 'path'; 10 | import glob from 'glob'; 11 | import _ from 'lodash'; 12 | 13 | type EslintError = { 14 | message: string, 15 | }; 16 | 17 | type Setup = { 18 | code: string, 19 | errors: EslintError[], 20 | options: unknown[], 21 | output: string, 22 | }; 23 | 24 | const formatCodeSnippet = (setup: Setup) => { 25 | const paragraphs: string[] = []; 26 | 27 | paragraphs.push(setup.code); 28 | 29 | if (setup.options) { 30 | paragraphs.push('// Options: ' + JSON.stringify(setup.options)); 31 | } 32 | 33 | if (setup.errors) { 34 | for (const message of setup.errors) { 35 | paragraphs.push('// Message: ' + message.message); 36 | } 37 | } 38 | 39 | if (setup.output) { 40 | paragraphs.push('// Fixed code: \n// ' + setup.output.split('\n').join('\n// ')); 41 | } 42 | 43 | return paragraphs.join('\n'); 44 | }; 45 | 46 | const getAssertions = () => { 47 | const assertionFiles = glob.sync(path.resolve(__dirname, '../test/rules/assertions/*.ts')); 48 | 49 | const assertionNames = _.map(assertionFiles, (filePath) => { 50 | return path.basename(filePath, '.ts'); 51 | }); 52 | 53 | const assertionCodes = _.map(assertionFiles, (filePath) => { 54 | const codes = require(filePath); 55 | 56 | return { 57 | invalid: _.map(codes.invalid, formatCodeSnippet), 58 | valid: _.map(codes.valid, formatCodeSnippet), 59 | }; 60 | }); 61 | 62 | return _.zipObject(assertionNames, assertionCodes); 63 | }; 64 | 65 | const updateDocuments = (assertions) => { 66 | const readmeDocumentPath = path.join(__dirname, '../README.md'); 67 | 68 | let documentBody = fs.readFileSync(readmeDocumentPath, 'utf8'); 69 | 70 | documentBody = documentBody.replace(//giu, (assertionsBlock) => { 71 | let exampleBody = ''; 72 | 73 | const ruleName = /assertions ([a-z]+)/iu.exec(assertionsBlock)?.[1]; 74 | 75 | if (!ruleName) { 76 | throw new Error('Rule name not found.'); 77 | } 78 | 79 | const ruleAssertions = assertions[ruleName]; 80 | 81 | if (!ruleAssertions) { 82 | throw new Error('No assertions available for rule "' + ruleName + '".'); 83 | } 84 | 85 | if (ruleAssertions.invalid.length) { 86 | exampleBody += 'The following patterns are considered problems:\n\n```js\n' + ruleAssertions.invalid.join('\n\n') + '\n```\n\n'; 87 | } 88 | 89 | if (ruleAssertions.valid.length) { 90 | exampleBody += 'The following patterns are not considered problems:\n\n```js\n' + ruleAssertions.valid.join('\n\n') + '\n```\n\n'; 91 | } 92 | 93 | return exampleBody; 94 | }); 95 | 96 | fs.writeFileSync(readmeDocumentPath, documentBody); 97 | }; 98 | 99 | updateDocuments(getAssertions()); 100 | 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "gajus@gajus.com", 4 | "name": "Gajus Kuizinas", 5 | "url": "http://gajus.com" 6 | }, 7 | "description": "Zod linting rules for ESLint.", 8 | "devDependencies": { 9 | "@semantic-release/commit-analyzer": "^9.0.2", 10 | "@semantic-release/github": "^8.0.6", 11 | "@semantic-release/npm": "^9.0.1", 12 | "@types/mocha": "^10.0.0", 13 | "@types/node": "^18.11.9", 14 | "eslint": "^8.27.0", 15 | "eslint-config-canonical": "37.0.3", 16 | "gitdown": "^3.1.5", 17 | "glob": "^8.0.3", 18 | "lodash": "^4.17.21", 19 | "mocha": "^10.1.0", 20 | "semantic-release": "^19.0.5", 21 | "tsx": "^3.12.1", 22 | "typescript": "^4.9.3" 23 | }, 24 | "engines": { 25 | "node": ">=12" 26 | }, 27 | "keywords": [ 28 | "eslint", 29 | "plugin", 30 | "zod" 31 | ], 32 | "license": "BSD-3-Clause", 33 | "main": "./dist/src/index.js", 34 | "name": "eslint-plugin-zod", 35 | "peerDependencies": { 36 | "eslint": ">=8.1.0" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/gajus/eslint-plugin-zod" 41 | }, 42 | "scripts": { 43 | "build": "npm run documentation && tsc", 44 | "documentation": "gitdown ./.README/README.md --output-file ./README.md && npm run documentation-add-assertions", 45 | "documentation-add-assertions": "tsx ./bin/readmeAssertions", 46 | "lint": "eslint ./src ./test && tsc --noEmit", 47 | "test": "mocha --require tsx test/**/*" 48 | }, 49 | "version": "0.0.0-development" 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import preferEnum from './rules/preferEnum'; 2 | import requireStrict from './rules/requireStrict'; 3 | 4 | const rules = { 5 | 'prefer-enum': preferEnum, 6 | 'require-strict': requireStrict, 7 | }; 8 | 9 | export = { 10 | rules, 11 | rulesConfig: { 12 | 'prefer-enum': 0, 13 | 'require-strict': 0, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/rules/preferEnum.ts: -------------------------------------------------------------------------------- 1 | const create = (context) => { 2 | return { 3 | CallExpression (node) { 4 | if (node.callee.object?.name !== 'z') { 5 | return; 6 | } 7 | 8 | if (node.callee.property.name !== 'union') { 9 | return; 10 | } 11 | 12 | const members = node.arguments[0].elements; 13 | 14 | const allMembersAreLiterals = members.every((member) => { 15 | return member.type === 'CallExpression' && member.callee.object.name === 'z' && member.callee.property.name === 'literal'; 16 | }); 17 | 18 | if (!allMembersAreLiterals) { 19 | return; 20 | } 21 | 22 | context.report({ 23 | message: 'Use z.enum().', 24 | node, 25 | }); 26 | }, 27 | }; 28 | }; 29 | 30 | export = { 31 | create, 32 | meta: { 33 | docs: { 34 | description: 'Prefers `z.enum` over a union of literals.', 35 | url: 'https://github.com/gajus/eslint-plugin-zod#eslint-plugin-zod-rules-prefer-enum', 36 | }, 37 | fixable: 'code', 38 | schema: [ 39 | { 40 | additionalProperties: false, 41 | properties: {}, 42 | type: 'object', 43 | }, 44 | ], 45 | type: 'problem', 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/rules/requireStrict.ts: -------------------------------------------------------------------------------- 1 | const defaultOptions = { 2 | allowPassthrough: true, 3 | }; 4 | 5 | const create = (context) => { 6 | return { 7 | CallExpression (node) { 8 | if (node.callee.object?.name !== 'z') { 9 | return; 10 | } 11 | 12 | if (node.callee.property.name !== 'object') { 13 | return; 14 | } 15 | 16 | const { 17 | allowPassthrough, 18 | } = context.options[0] ?? defaultOptions; 19 | 20 | if (!node.parent.property) { 21 | context.report({ 22 | fix: (fixer) => { 23 | return fixer.insertTextAfter(node, '.strict()'); 24 | }, 25 | message: 'Add a strict() call to the schema.', 26 | node, 27 | }); 28 | } else if ( 29 | // z.object().strict() 30 | node.parent?.property?.name !== 'strict' && 31 | // z.object().merge().strict() 32 | node.parent?.parent?.parent?.property?.name !== 'strict' 33 | ) { 34 | if (node.parent?.property?.name === 'passthrough' && allowPassthrough) { 35 | return; 36 | } 37 | 38 | if (node.parent?.property?.name === 'and') { 39 | // Ignore .and() calls 40 | return; 41 | } 42 | 43 | // As far as I can think, in cases where the property name is not-strict, 44 | // e.g. passthrough, we should not add a strict() call. 45 | context.report({ 46 | message: 'Add a strict() call to the schema.', 47 | node, 48 | }); 49 | } 50 | }, 51 | }; 52 | }; 53 | 54 | export = { 55 | create, 56 | meta: { 57 | docs: { 58 | description: 'Requires that objects are initialized with .strict().', 59 | url: 'https://github.com/gajus/eslint-plugin-zod#eslint-plugin-zod-rules-require-strict', 60 | }, 61 | fixable: 'code', 62 | schema: [ 63 | { 64 | additionalProperties: false, 65 | properties: { 66 | allowPassthrough: { 67 | default: true, 68 | type: 'boolean', 69 | }, 70 | }, 71 | type: 'object', 72 | }, 73 | ], 74 | type: 'problem', 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /test/rules/assertions/preferEnum.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | invalid: [ 3 | { 4 | code: 'z.union([z.literal(\'foo\'), z.literal(\'bar\')])', 5 | errors: [ 6 | { 7 | message: 'Use z.enum().', 8 | }, 9 | ], 10 | }, 11 | ], 12 | valid: [ 13 | { 14 | code: 'z.enum([\'foo\', \'bar\'])', 15 | }, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /test/rules/assertions/requireStrict.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | invalid: [ 3 | { 4 | code: 'z.object()', 5 | errors: [ 6 | { 7 | message: 'Add a strict() call to the schema.', 8 | }, 9 | ], 10 | output: 'z.object().strict()', 11 | }, 12 | { 13 | code: 'z.object().passthrough()', 14 | errors: [ 15 | { 16 | message: 'Add a strict() call to the schema.', 17 | }, 18 | ], 19 | options: [ 20 | { 21 | allowPassthrough: false, 22 | }, 23 | ], 24 | }, 25 | ], 26 | valid: [ 27 | { 28 | code: 'foo()', 29 | }, 30 | { 31 | // Not a Zod schema 32 | code: 'b.object()', 33 | }, 34 | { 35 | code: 'z.object().strict()', 36 | }, 37 | { 38 | code: 'z.object().merge().strict()', 39 | }, 40 | { 41 | // Ignore .and() calls 42 | code: 'z.object().and()', 43 | }, 44 | { 45 | code: 'z.object().passthrough()', 46 | }, 47 | { 48 | code: 'z.object().passthrough()', 49 | options: [ 50 | { 51 | allowPassthrough: true, 52 | }, 53 | ], 54 | }, 55 | ], 56 | }; 57 | -------------------------------------------------------------------------------- /test/rules/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable @typescript-eslint/no-require-imports */ 3 | 4 | import { 5 | RuleTester, 6 | } from 'eslint'; 7 | import { 8 | camelCase, 9 | } from 'lodash'; 10 | import plugin from '../../src'; 11 | 12 | const ruleTester = new RuleTester({ 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | }, 16 | }); 17 | 18 | const reportingRules = [ 19 | 'prefer-enum', 20 | 'require-strict', 21 | ]; 22 | 23 | for (const ruleName of reportingRules) { 24 | const assertions = require('./assertions/' + camelCase(ruleName)); 25 | 26 | ruleTester.run(ruleName, plugin.rules[ruleName], assertions.default); 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "lib": [ 8 | "es2021" 9 | ], 10 | "moduleResolution": "node", 11 | "noImplicitAny": false, 12 | "noImplicitReturns": true, 13 | "useUnknownInCatchVariables": false, 14 | "outDir": "dist", 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "es2018" 18 | }, 19 | "exclude": [ 20 | "dist", 21 | "node_modules" 22 | ], 23 | "include": [ 24 | "bin", 25 | "src", 26 | "test" 27 | ] 28 | } --------------------------------------------------------------------------------