eslint-plugin-zod-utils 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,7 @@ Legacy eslintrc:
49
49
 
50
50
  ### `zod-utils/no-inline-zod-schema`
51
51
 
52
- Requires Zod schemas to be created as module-level variable declarations. This avoids recreating schemas inside functions, callbacks, render paths, hooks, or argument lists.
52
+ Requires Zod schemas to be created during module initialization. This avoids recreating schemas inside functions, callbacks, render paths, hooks, or instance fields.
53
53
 
54
54
  This rule was inspired by the motivation behind [`babel-plugin-zod-hoist`](https://github.com/gajus/babel-plugin-zod-hoist#motivation), which documents the cost of repeatedly initializing equivalent Zod schemas and the benefits of hoisting them.
55
55
 
@@ -70,10 +70,10 @@ function parseUser(input: unknown) {
70
70
  ```ts
71
71
  import { z } from "zod";
72
72
 
73
- app.post("/users", {
74
- body: z.object({
73
+ app.post("/users", (request) => {
74
+ return z.object({
75
75
  id: z.string(),
76
- }),
76
+ }).parse(request.body);
77
77
  });
78
78
  ```
79
79
 
@@ -91,7 +91,38 @@ function parseUser(input: unknown) {
91
91
  }
92
92
  ```
93
93
 
94
+ ```ts
95
+ import { z } from "zod";
96
+
97
+ app.post("/users", {
98
+ body: z.object({
99
+ id: z.string(),
100
+ }),
101
+ });
102
+ ```
103
+
94
104
  The rule understands `import { z } from "zod"`, `import * as zod from "zod"`, aliased `z` imports, and direct named schema factories such as `import { object, string } from "zod"`.
105
+ It also understands default imports such as `import z from "zod"`.
106
+
107
+ When type information is available through `@typescript-eslint/parser`, the rule also reports repeated derived schema creation from imported Zod schemas:
108
+
109
+ ```ts
110
+ import { UserSchema } from "./schemas";
111
+
112
+ function getPublicSchema() {
113
+ return UserSchema.pick({ id: true });
114
+ }
115
+ ```
116
+
117
+ ```ts
118
+ import { BaseSchema, TenantIdSchema } from "./schemas";
119
+
120
+ function getTenantSchema() {
121
+ return BaseSchema.extend({ tenantId: TenantIdSchema });
122
+ }
123
+ ```
124
+
125
+ This type-aware detection does not use schema-name conventions. If type information is unavailable, imported schema roots are not inferred from names such as `UserSchema` or `UserZodSchema`. The rule also does not assume a global `z` identifier refers to Zod.
95
126
 
96
127
  ## Development
97
128
 
package/dist/index.cjs CHANGED
@@ -88,6 +88,37 @@ var ZOD_FACTORY_IMPORTS = /* @__PURE__ */ new Set([
88
88
  "xid"
89
89
  ]);
90
90
  var ZOD_NAMESPACE_IMPORTS = /* @__PURE__ */ new Set(["z", "coerce", "iso"]);
91
+ var ZOD_EXECUTION_METHODS = /* @__PURE__ */ new Set([
92
+ "parse",
93
+ "parseAsync",
94
+ "safeParse",
95
+ "safeParseAsync"
96
+ ]);
97
+ var ZOD_SCHEMA_COMBINATOR_METHODS = /* @__PURE__ */ new Set([
98
+ "and",
99
+ "array",
100
+ "brand",
101
+ "catchall",
102
+ "deepPartial",
103
+ "describe",
104
+ "extend",
105
+ "merge",
106
+ "nullable",
107
+ "nullish",
108
+ "omit",
109
+ "optional",
110
+ "or",
111
+ "partial",
112
+ "passthrough",
113
+ "pick",
114
+ "readonly",
115
+ "refine",
116
+ "required",
117
+ "strict",
118
+ "strip",
119
+ "superRefine",
120
+ "transform"
121
+ ]);
91
122
  function findVariable(scope, name) {
92
123
  let currentScope = scope;
93
124
  while (currentScope) {
@@ -121,8 +152,33 @@ function getRootIdentifier(node) {
121
152
  return null;
122
153
  }
123
154
  }
155
+ function getRootMethodName(node) {
156
+ let current = node;
157
+ let rootMethodName = null;
158
+ while (true) {
159
+ if (current.type === "CallExpression") {
160
+ current = current.callee;
161
+ continue;
162
+ }
163
+ if (current.type === "MemberExpression") {
164
+ if (current.object.type === "Identifier" && !current.computed && current.property.type === "Identifier") {
165
+ rootMethodName = current.property.name;
166
+ }
167
+ current = current.object;
168
+ continue;
169
+ }
170
+ return rootMethodName;
171
+ }
172
+ }
173
+ function getCallMethodName(node) {
174
+ const { callee } = node;
175
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
176
+ return callee.property.name;
177
+ }
178
+ return null;
179
+ }
124
180
  function isZodImportSpecifier(node) {
125
- if (node.type !== "ImportNamespaceSpecifier" && node.type !== "ImportSpecifier") {
181
+ if (node.type !== "ImportDefaultSpecifier" && node.type !== "ImportNamespaceSpecifier" && node.type !== "ImportSpecifier") {
126
182
  return false;
127
183
  }
128
184
  const declaration = node.parent;
@@ -132,7 +188,7 @@ function isZodImportSpecifier(node) {
132
188
  if (declaration.source.value !== "zod") {
133
189
  return false;
134
190
  }
135
- if (node.type === "ImportNamespaceSpecifier") {
191
+ if (node.type === "ImportDefaultSpecifier" || node.type === "ImportNamespaceSpecifier") {
136
192
  return true;
137
193
  }
138
194
  if (node.importKind === "type" || declaration.importKind === "type") {
@@ -141,27 +197,38 @@ function isZodImportSpecifier(node) {
141
197
  const importedName = node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
142
198
  return ZOD_NAMESPACE_IMPORTS.has(importedName) || ZOD_FACTORY_IMPORTS.has(importedName);
143
199
  }
144
- function unwrapExpression(node) {
145
- let current = node;
146
- while (current.parent?.type === "TSAsExpression" || current.parent?.type === "TSNonNullExpression" || current.parent?.type === "TSSatisfiesExpression" || current.parent?.type === "TSTypeAssertion") {
147
- current = current.parent;
148
- }
149
- return current;
200
+ function hasFullTypeInformation(services) {
201
+ return services.program !== null;
202
+ }
203
+ function isZodDeclarationFile(fileName) {
204
+ return /(?:^|[/\\])node_modules[/\\]zod[/\\]/u.test(fileName);
150
205
  }
151
- function isModuleLevelVariableInitializer(node) {
152
- const expression = unwrapExpression(node);
153
- if (expression.parent?.type !== "VariableDeclarator") {
206
+ function isZodSchemaType(type, services, seen = /* @__PURE__ */ new Set()) {
207
+ if (!hasFullTypeInformation(services)) {
154
208
  return false;
155
209
  }
156
- if (expression.parent.init !== expression) {
210
+ if (seen.has(type)) {
157
211
  return false;
158
212
  }
159
- const declaration = expression.parent.parent;
160
- if (declaration.type !== "VariableDeclaration") {
161
- return false;
213
+ seen.add(type);
214
+ const symbols = [type.getSymbol(), type.aliasSymbol];
215
+ if (symbols.some(
216
+ (symbol) => symbol?.getDeclarations()?.some(
217
+ (declaration) => isZodDeclarationFile(declaration.getSourceFile().fileName)
218
+ )
219
+ )) {
220
+ return true;
221
+ }
222
+ if (type.isUnionOrIntersection()) {
223
+ return type.types.some((subType) => isZodSchemaType(subType, services, seen));
162
224
  }
163
- const container = declaration.parent;
164
- return container.type === "Program" || container.type === "ExportNamedDeclaration" && container.parent.type === "Program";
225
+ if (type.getBaseTypes()?.some((baseType) => isZodSchemaType(baseType, services, seen))) {
226
+ return true;
227
+ }
228
+ const parseSymbol = type.getProperty("parse");
229
+ return parseSymbol?.getDeclarations()?.some(
230
+ (declaration) => isZodDeclarationFile(declaration.getSourceFile().fileName)
231
+ ) ?? false;
165
232
  }
166
233
  var noInlineZodSchema = import_utils.ESLintUtils.RuleCreator(
167
234
  (ruleName2) => `https://www.npmjs.com/package/eslint-plugin-zod-utils#${ruleName2}`
@@ -173,14 +240,22 @@ var noInlineZodSchema = import_utils.ESLintUtils.RuleCreator(
173
240
  description: "Disallow creating Zod schemas outside module scope."
174
241
  },
175
242
  messages: {
176
- inlineSchema: "Create Zod schemas at module scope instead of inline inside functions, callbacks, classes, or arguments."
243
+ inlineSchema: "Create Zod schemas during module initialization instead of inside functions, callbacks, or other repeated execution paths."
177
244
  },
178
245
  schema: []
179
246
  },
180
247
  defaultOptions: [],
181
248
  create(context) {
182
249
  const sourceCode = context.sourceCode;
250
+ const parserServices = import_utils.ESLintUtils.getParserServices(context, true);
251
+ function isZodExecutionCall(node) {
252
+ const methodName = getCallMethodName(node);
253
+ return methodName !== null && ZOD_EXECUTION_METHODS.has(methodName);
254
+ }
183
255
  function isZodSchemaCall(node) {
256
+ if (isZodExecutionCall(node)) {
257
+ return false;
258
+ }
184
259
  const root = getRootIdentifier(node.callee);
185
260
  if (!root) {
186
261
  return false;
@@ -189,10 +264,41 @@ var noInlineZodSchema = import_utils.ESLintUtils.RuleCreator(
189
264
  const variable = findVariable(scope, root.name);
190
265
  return variable?.defs.some((definition) => isZodImportSpecifier(definition.node)) ?? false;
191
266
  }
192
- function hasZodCallAncestor(node) {
267
+ function isTypedZodSchemaCombinatorCall(node) {
268
+ const rootMethodName = getRootMethodName(node);
269
+ if (rootMethodName === null || !ZOD_SCHEMA_COMBINATOR_METHODS.has(rootMethodName)) {
270
+ return false;
271
+ }
272
+ const root = getRootIdentifier(node.callee);
273
+ if (!root) {
274
+ return false;
275
+ }
276
+ if (!hasFullTypeInformation(parserServices)) {
277
+ return false;
278
+ }
279
+ const type = parserServices.getTypeAtLocation(root);
280
+ return isZodSchemaType(type, parserServices);
281
+ }
282
+ function isSchemaCreationCall(node) {
283
+ return isZodSchemaCall(node) || isTypedZodSchemaCombinatorCall(node);
284
+ }
285
+ function hasSchemaCreationCallAncestor(node) {
193
286
  let current = node.parent;
194
287
  while (current) {
195
- if (current.type === "CallExpression" && isZodSchemaCall(current)) {
288
+ if (current.type === "CallExpression" && isSchemaCreationCall(current)) {
289
+ return true;
290
+ }
291
+ current = current.parent;
292
+ }
293
+ return false;
294
+ }
295
+ function isInsideRepeatedExecutionPath(node) {
296
+ let current = node.parent;
297
+ while (current) {
298
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
299
+ return true;
300
+ }
301
+ if (current.type === "PropertyDefinition" && !current.static) {
196
302
  return true;
197
303
  }
198
304
  current = current.parent;
@@ -201,13 +307,13 @@ var noInlineZodSchema = import_utils.ESLintUtils.RuleCreator(
201
307
  }
202
308
  return {
203
309
  CallExpression(node) {
204
- if (!isZodSchemaCall(node)) {
310
+ if (!isSchemaCreationCall(node)) {
205
311
  return;
206
312
  }
207
- if (hasZodCallAncestor(node)) {
313
+ if (hasSchemaCreationCallAncestor(node)) {
208
314
  return;
209
315
  }
210
- if (isModuleLevelVariableInitializer(node)) {
316
+ if (!isInsideRepeatedExecutionPath(node)) {
211
317
  return;
212
318
  }
213
319
  context.report({
@@ -227,7 +333,7 @@ var rules = {
227
333
  var plugin = {
228
334
  meta: {
229
335
  name: "eslint-plugin-zod-utils",
230
- version: "0.1.0"
336
+ version: "1.0.3"
231
337
  },
232
338
  rules,
233
339
  configs: {}
package/dist/index.js CHANGED
@@ -60,6 +60,37 @@ var ZOD_FACTORY_IMPORTS = /* @__PURE__ */ new Set([
60
60
  "xid"
61
61
  ]);
62
62
  var ZOD_NAMESPACE_IMPORTS = /* @__PURE__ */ new Set(["z", "coerce", "iso"]);
63
+ var ZOD_EXECUTION_METHODS = /* @__PURE__ */ new Set([
64
+ "parse",
65
+ "parseAsync",
66
+ "safeParse",
67
+ "safeParseAsync"
68
+ ]);
69
+ var ZOD_SCHEMA_COMBINATOR_METHODS = /* @__PURE__ */ new Set([
70
+ "and",
71
+ "array",
72
+ "brand",
73
+ "catchall",
74
+ "deepPartial",
75
+ "describe",
76
+ "extend",
77
+ "merge",
78
+ "nullable",
79
+ "nullish",
80
+ "omit",
81
+ "optional",
82
+ "or",
83
+ "partial",
84
+ "passthrough",
85
+ "pick",
86
+ "readonly",
87
+ "refine",
88
+ "required",
89
+ "strict",
90
+ "strip",
91
+ "superRefine",
92
+ "transform"
93
+ ]);
63
94
  function findVariable(scope, name) {
64
95
  let currentScope = scope;
65
96
  while (currentScope) {
@@ -93,8 +124,33 @@ function getRootIdentifier(node) {
93
124
  return null;
94
125
  }
95
126
  }
127
+ function getRootMethodName(node) {
128
+ let current = node;
129
+ let rootMethodName = null;
130
+ while (true) {
131
+ if (current.type === "CallExpression") {
132
+ current = current.callee;
133
+ continue;
134
+ }
135
+ if (current.type === "MemberExpression") {
136
+ if (current.object.type === "Identifier" && !current.computed && current.property.type === "Identifier") {
137
+ rootMethodName = current.property.name;
138
+ }
139
+ current = current.object;
140
+ continue;
141
+ }
142
+ return rootMethodName;
143
+ }
144
+ }
145
+ function getCallMethodName(node) {
146
+ const { callee } = node;
147
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
148
+ return callee.property.name;
149
+ }
150
+ return null;
151
+ }
96
152
  function isZodImportSpecifier(node) {
97
- if (node.type !== "ImportNamespaceSpecifier" && node.type !== "ImportSpecifier") {
153
+ if (node.type !== "ImportDefaultSpecifier" && node.type !== "ImportNamespaceSpecifier" && node.type !== "ImportSpecifier") {
98
154
  return false;
99
155
  }
100
156
  const declaration = node.parent;
@@ -104,7 +160,7 @@ function isZodImportSpecifier(node) {
104
160
  if (declaration.source.value !== "zod") {
105
161
  return false;
106
162
  }
107
- if (node.type === "ImportNamespaceSpecifier") {
163
+ if (node.type === "ImportDefaultSpecifier" || node.type === "ImportNamespaceSpecifier") {
108
164
  return true;
109
165
  }
110
166
  if (node.importKind === "type" || declaration.importKind === "type") {
@@ -113,27 +169,38 @@ function isZodImportSpecifier(node) {
113
169
  const importedName = node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
114
170
  return ZOD_NAMESPACE_IMPORTS.has(importedName) || ZOD_FACTORY_IMPORTS.has(importedName);
115
171
  }
116
- function unwrapExpression(node) {
117
- let current = node;
118
- while (current.parent?.type === "TSAsExpression" || current.parent?.type === "TSNonNullExpression" || current.parent?.type === "TSSatisfiesExpression" || current.parent?.type === "TSTypeAssertion") {
119
- current = current.parent;
120
- }
121
- return current;
172
+ function hasFullTypeInformation(services) {
173
+ return services.program !== null;
174
+ }
175
+ function isZodDeclarationFile(fileName) {
176
+ return /(?:^|[/\\])node_modules[/\\]zod[/\\]/u.test(fileName);
122
177
  }
123
- function isModuleLevelVariableInitializer(node) {
124
- const expression = unwrapExpression(node);
125
- if (expression.parent?.type !== "VariableDeclarator") {
178
+ function isZodSchemaType(type, services, seen = /* @__PURE__ */ new Set()) {
179
+ if (!hasFullTypeInformation(services)) {
126
180
  return false;
127
181
  }
128
- if (expression.parent.init !== expression) {
182
+ if (seen.has(type)) {
129
183
  return false;
130
184
  }
131
- const declaration = expression.parent.parent;
132
- if (declaration.type !== "VariableDeclaration") {
133
- return false;
185
+ seen.add(type);
186
+ const symbols = [type.getSymbol(), type.aliasSymbol];
187
+ if (symbols.some(
188
+ (symbol) => symbol?.getDeclarations()?.some(
189
+ (declaration) => isZodDeclarationFile(declaration.getSourceFile().fileName)
190
+ )
191
+ )) {
192
+ return true;
193
+ }
194
+ if (type.isUnionOrIntersection()) {
195
+ return type.types.some((subType) => isZodSchemaType(subType, services, seen));
134
196
  }
135
- const container = declaration.parent;
136
- return container.type === "Program" || container.type === "ExportNamedDeclaration" && container.parent.type === "Program";
197
+ if (type.getBaseTypes()?.some((baseType) => isZodSchemaType(baseType, services, seen))) {
198
+ return true;
199
+ }
200
+ const parseSymbol = type.getProperty("parse");
201
+ return parseSymbol?.getDeclarations()?.some(
202
+ (declaration) => isZodDeclarationFile(declaration.getSourceFile().fileName)
203
+ ) ?? false;
137
204
  }
138
205
  var noInlineZodSchema = ESLintUtils.RuleCreator(
139
206
  (ruleName2) => `https://www.npmjs.com/package/eslint-plugin-zod-utils#${ruleName2}`
@@ -145,14 +212,22 @@ var noInlineZodSchema = ESLintUtils.RuleCreator(
145
212
  description: "Disallow creating Zod schemas outside module scope."
146
213
  },
147
214
  messages: {
148
- inlineSchema: "Create Zod schemas at module scope instead of inline inside functions, callbacks, classes, or arguments."
215
+ inlineSchema: "Create Zod schemas during module initialization instead of inside functions, callbacks, or other repeated execution paths."
149
216
  },
150
217
  schema: []
151
218
  },
152
219
  defaultOptions: [],
153
220
  create(context) {
154
221
  const sourceCode = context.sourceCode;
222
+ const parserServices = ESLintUtils.getParserServices(context, true);
223
+ function isZodExecutionCall(node) {
224
+ const methodName = getCallMethodName(node);
225
+ return methodName !== null && ZOD_EXECUTION_METHODS.has(methodName);
226
+ }
155
227
  function isZodSchemaCall(node) {
228
+ if (isZodExecutionCall(node)) {
229
+ return false;
230
+ }
156
231
  const root = getRootIdentifier(node.callee);
157
232
  if (!root) {
158
233
  return false;
@@ -161,10 +236,41 @@ var noInlineZodSchema = ESLintUtils.RuleCreator(
161
236
  const variable = findVariable(scope, root.name);
162
237
  return variable?.defs.some((definition) => isZodImportSpecifier(definition.node)) ?? false;
163
238
  }
164
- function hasZodCallAncestor(node) {
239
+ function isTypedZodSchemaCombinatorCall(node) {
240
+ const rootMethodName = getRootMethodName(node);
241
+ if (rootMethodName === null || !ZOD_SCHEMA_COMBINATOR_METHODS.has(rootMethodName)) {
242
+ return false;
243
+ }
244
+ const root = getRootIdentifier(node.callee);
245
+ if (!root) {
246
+ return false;
247
+ }
248
+ if (!hasFullTypeInformation(parserServices)) {
249
+ return false;
250
+ }
251
+ const type = parserServices.getTypeAtLocation(root);
252
+ return isZodSchemaType(type, parserServices);
253
+ }
254
+ function isSchemaCreationCall(node) {
255
+ return isZodSchemaCall(node) || isTypedZodSchemaCombinatorCall(node);
256
+ }
257
+ function hasSchemaCreationCallAncestor(node) {
165
258
  let current = node.parent;
166
259
  while (current) {
167
- if (current.type === "CallExpression" && isZodSchemaCall(current)) {
260
+ if (current.type === "CallExpression" && isSchemaCreationCall(current)) {
261
+ return true;
262
+ }
263
+ current = current.parent;
264
+ }
265
+ return false;
266
+ }
267
+ function isInsideRepeatedExecutionPath(node) {
268
+ let current = node.parent;
269
+ while (current) {
270
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
271
+ return true;
272
+ }
273
+ if (current.type === "PropertyDefinition" && !current.static) {
168
274
  return true;
169
275
  }
170
276
  current = current.parent;
@@ -173,13 +279,13 @@ var noInlineZodSchema = ESLintUtils.RuleCreator(
173
279
  }
174
280
  return {
175
281
  CallExpression(node) {
176
- if (!isZodSchemaCall(node)) {
282
+ if (!isSchemaCreationCall(node)) {
177
283
  return;
178
284
  }
179
- if (hasZodCallAncestor(node)) {
285
+ if (hasSchemaCreationCallAncestor(node)) {
180
286
  return;
181
287
  }
182
- if (isModuleLevelVariableInitializer(node)) {
288
+ if (!isInsideRepeatedExecutionPath(node)) {
183
289
  return;
184
290
  }
185
291
  context.report({
@@ -199,7 +305,7 @@ var rules = {
199
305
  var plugin = {
200
306
  meta: {
201
307
  name: "eslint-plugin-zod-utils",
202
- version: "0.1.0"
308
+ version: "1.0.3"
203
309
  },
204
310
  rules,
205
311
  configs: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-zod-utils",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "ESLint utilities for safer Zod schema usage.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -46,7 +46,8 @@
46
46
  "tsup": "^8.5.1",
47
47
  "typescript": "^6.0.3",
48
48
  "typescript-eslint": "^8.60.1",
49
- "vitest": "^4.1.8"
49
+ "vitest": "^4.1.8",
50
+ "zod": "^4.4.3"
50
51
  },
51
52
  "dependencies": {
52
53
  "@typescript-eslint/utils": "^8.60.1"