noctcore-contracts/schema-enum-field-consistency
A field that is an enum in one zod object schema must not be
z.string()in another schema of the same module.
Recommended preset: error · Autofix: no · Suggestions: no · Type information: not needed
When schemas double as wire types (tRPC procedures, a shared contract package), the output schema is
the type the client imports. If a field is an enum on the inputs but z.string() on the output, the
widened type leaks string to every consumer, which then narrows or casts by hand. Each schema is
correct on its own, so neither the compiler nor review catches it.
What it flags
Section titled “What it flags”Per file, purely syntactic, no type information. The rule collects every property of every
z.object / z.strictObject / z.looseObject and every .extend / .safeExtend shape, unwraps
.optional(), .nullable(), .nullish(), .default(), .prefault(), .catch(), .describe(),
.meta() and .readonly(), and classifies each value:
- enum:
z.enum(...),z.nativeEnum(...), az.unionof onlyz.literals, a multi-valuez.literal([...]),.extract()/.exclude()of an enum, or an identifier whose same-file declaration is one of those (or an import matchingenumIdentifierPattern) - string: a chain rooted at
z.string()with no.pipe()or.transform() - anything else is ignored, including
z.union([z.string(), z.null()])
Every string occurrence of a key that is enum elsewhere in the file is reported. No autofix: the right fix may be a data migration (the stored column was free text), not a schema edit.
// the output widens what both inputs narrowexport const statusSchema = z.enum(['OPEN', 'CLOSED']);
export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });export const ticketUpdateInput = z.object({ status: statusSchema.optional() });export const ticketOutput = z.object({ id: z.string(), status: z.string().nullable(),});// the output reuses the enumexport const statusSchema = z.enum(['OPEN', 'CLOSED']);
export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });export const ticketUpdateInput = z.object({ status: statusSchema.optional() });export const ticketOutput = z.object({ id: z.string(), status: statusSchema.nullable(),});What it does not flag
Section titled “What it does not flag”z.union([z.string(), z.null()])and anyz.string()chain with.pipe()or.transform(): neither is a plain string.- A single
z.literal('X'): a constant, not an enum. - An imported identifier, unless its name matches
enumIdentifierPattern(see Options). - An enum in another file: the scope is one module.
- Keys in
ignoreFields, computed keys, and schemas built from a namespace not listed inzodIdentifiers.
Options
Section titled “Options”| Option | Type | Default | Meaning |
|---|---|---|---|
zodIdentifiers |
string[] |
['z'] |
Names zod is imported under. |
ignoreFields |
string[] |
[] |
Keys that genuinely mean different things in two schemas. |
enumIdentifierPattern |
string (regex source) |
unset | An imported identifier counts as an enum only when its name matches. |
Imported identifiers are not assumed to be enums by default, so email: emailSchema (a string
schema from another module) is not flagged against email: z.string(). If your project names its
enum schemas consistently, opt them in:
'noctcore-contracts/schema-enum-field-consistency': ['error', { zodIdentifiers: ['z'], enumIdentifierPattern: '(Status|Kind|Role)Schema$', ignoreFields: ['type'],}]Scope the rule to your schema modules with the config block’s files (for example
files: ['src/schemas/**/*.ts']); the rule itself assumes no layout.
When not to use it
Section titled “When not to use it”If a module intentionally keeps free-text and enum variants of the same key (a legacy import format
beside the validated one), list that key in ignoreFields rather than turning the rule off.