ESLint Plugin
@umpire/eslint-plugin adds six ESLint rules that catch umpire mistakes before your tests run. Two catch likely bugs (unknown fields, inline instantiation), three catch logical impossibilities that would silently produce fields that can never be enabled, and one catches database-owned fields leaking into write candidates.
Compatibility
Section titled “Compatibility”| ESLint | ≥ 9.0.0 |
| Oxlint | JS plugins (jsPlugins) |
| Config format | Flat config only (eslint.config.js) — legacy .eslintrc is not supported |
| Node | Whatever ESLint 9 requires (≥ 18.18) |
eslint is an optional peer dependency — it’s only needed when using ESLint directly. Oxlint users don’t need it installed.
Install
Section titled “Install”npm install --save-dev @umpire/eslint-pluginAdd the recommended config to your eslint.config.js:
import umpire from '@umpire/eslint-plugin'
export default [ umpire.configs.recommended, // ... rest of your config]This enables five rules at the severities shown in the table below. The sixth rule (no-write-owned-fields) is opt-in because it’s Drizzle-specific. To customize individual rules, spread the config and override:
import umpire from '@umpire/eslint-plugin'
export default [ { ...umpire.configs.recommended, rules: { ...umpire.configs.recommended.rules, '@umpire/no-unknown-fields': 'error', // promote to error '@umpire/no-inline-umpire-init': 'off', // disable if you prefer useMemo discipline elsewhere }, },]Or register the plugin manually and pick individual rules:
import umpire from '@umpire/eslint-plugin'
export default [ { plugins: { '@umpire': umpire }, rules: { '@umpire/no-self-disable': 'error', '@umpire/no-circular-requires': 'error', }, },]Oxlint setup
Section titled “Oxlint setup”Add the plugin to jsPlugins, then enable rules by their full name:
{ "jsPlugins": ["@umpire/eslint-plugin"], "rules": { "@umpire/eslint-plugin/no-self-disable": "error" }}Rule names use the @umpire/eslint-plugin/ prefix in Oxlint, unlike ESLint’s @umpire/ namespace.
| Rule | Severity | What it catches |
|---|---|---|
no-unknown-fields | warn | Field names in rules that aren’t declared in fields |
no-inline-umpire-init | warn | umpire() called inside a component or hook body without useMemo |
no-write-owned-fields | opt-in | Database-owned fields (like id) submitted through write candidates or missing from Drizzle excludes |
no-self-disable | error | A field listed as both source and target of disables() |
no-contradicting-rules | error | requires/disables pairs that make a field permanently unavailable |
no-circular-requires | error | Circular requires chains where fields mutually depend on each other |
warn rules are style/perf issues you should fix. error rules are logical impossibilities — the field they affect can never be enabled, ever.
no-unknown-fields
Section titled “no-unknown-fields”Catches field names used inside rules that don’t match any key in the fields config. Typos here fail silently at runtime — check() just ignores the unknown dependency.
// ✗ — 'submitt' is not declared in fieldsconst ump = umpire({ fields: { mode: {}, details: {}, submit: {} }, rules: [ requires('submitt', 'mode'), // typo ],})
// ✓const ump = umpire({ fields: { mode: {}, details: {}, submit: {} }, rules: [ requires('submit', 'mode'), ],})no-inline-umpire-init
Section titled “no-inline-umpire-init”umpire() builds a dependency graph when called. Calling it inside a React component or hook body without useMemo rebuilds that graph on every render.
// ✗ — umpire() runs on every renderfunction CheckoutForm() { const ump = umpire({ fields: { card: {}, billing: {}, submit: {} }, rules: [requires('submit', 'card')], }) const { check } = useUmpire(ump, values) // ...}
// ✓ — defined once at module scopeconst ump = umpire({ fields: { card: {}, billing: {}, submit: {} }, rules: [requires('submit', 'card')],})
function CheckoutForm() { const { check } = useUmpire(ump, values) // ...}
// ✓ — or memoized if it depends on propsfunction CheckoutForm({ requireBilling }: Props) { const ump = useMemo(() => umpire({ fields: { card: {}, billing: {}, submit: {} }, rules: [ requires('submit', 'card'), ...(requireBilling ? [requires('submit', 'billing')] : []), ], }), [requireBilling]) // ...}no-self-disable
Section titled “no-self-disable”A field listed as both the source and a target of disables() would disable itself when it has a value — making it immediately unavailable.
// ✗ — 'promo' disables itselfconst ump = umpire({ fields: { promo: {}, discount: {} }, rules: [ disables('promo', ['promo', 'discount']), ],})
// ✓const ump = umpire({ fields: { promo: {}, discount: {} }, rules: [ disables('promo', ['discount']), ],})no-contradicting-rules
Section titled “no-contradicting-rules”Detects requires/disables pairs that make a field’s requirement permanently unsatisfiable. Two patterns are caught:
Case A — the dependency disables the requiring field: requires(A, B) + disables(B, [A]). A needs B to be available, but when B is satisfied it disables A — so A can never hold a value.
Case B — the field disables its own dependency: requires(A, B) + disables(A, [B]). A needs B, but when A is satisfied it disables B — so A’s own requirement can never hold while A has a value.
// ✗ — Case A: 'annual' requires 'plan', but 'plan' disables 'annual'const ump = umpire({ fields: { plan: {}, annual: {}, discount: {} }, rules: [ requires('annual', 'plan'), disables('plan', ['annual']), ],})
// ✓ — remove the contradicting disables, or restructure the dependencyconst ump = umpire({ fields: { plan: {}, annual: {}, discount: {} }, rules: [ requires('annual', 'plan'), disables('plan', ['discount']), ],})no-circular-requires
Section titled “no-circular-requires”Detects cycles in the requires dependency graph. If A requires B and B requires A (or a longer chain), neither field can ever satisfy its own requirement.
// ✗ — A and B mutually require each otherconst ump = umpire({ fields: { a: {}, b: {}, c: {} }, rules: [ requires('a', 'b'), requires('b', 'a'), ],})
// ✗ — three-node cycle: a → b → c → aconst ump = umpire({ fields: { a: {}, b: {}, c: {} }, rules: [ requires('a', 'b'), requires('b', 'c'), requires('c', 'a'), ],})
// ✓ — linear dependency, no cycleconst ump = umpire({ fields: { a: {}, b: {}, c: {} }, rules: [ requires('a', 'b'), requires('b', 'c'), ],})no-write-owned-fields
Section titled “no-write-owned-fields”Drizzle tables have columns the database owns — primary keys, auto-generated timestamps, columns with SQL defaults. These should never appear in write candidates. If you forget, checkDrizzleCreate or checkDrizzlePatch will catch them at runtime (via nonWritableKeys), but runtime errors happen after the damage is done. This rule moves that check to lint time.
The rule performs two checks:
- Write candidates — flags literal object properties inside
checkCreate()andcheckPatch()calls that match the configured field names (defaults toid). - Drizzle helpers — flags
fromDrizzleTable()andfromDrizzleModel()calls that don’t explicitly exclude those fields via theexcludeoption.
Configuration
Section titled “Configuration”This rule is opt-in — it ships with the plugin but is not enabled by the recommended config, because it’s Drizzle-specific. Add it explicitly:
import umpire from '@umpire/eslint-plugin'
export default [ umpire.configs.recommended, { rules: { '@umpire/no-write-owned-fields': ['warn', { fieldNames: ['id', 'createdAt', 'updatedAt'], }], }, },]Examples
Section titled “Examples”A Drizzle table with database-owned columns:
const users = pgTable('users', { id: serial().primaryKey(), email: varchar({ length: 255 }).notNull(), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().$onUpdate(() => new Date()), companyName: text(),})Write candidates — the rule flags database-owned fields in the candidate object:
// ✗ — 'id' is a database-owned fieldconst result = checkCreate(userUmp, { id: 123, email: 'alex@example.com', companyName: 'Acme',})
// ✓ — database-owned fields are absentconst result = checkCreate(userUmp, { email: 'alex@example.com', companyName: 'Acme',})Drizzle helpers — the rule flags calls that omit the exclude option:
// ✗ — 'id' is not excludedconst base = fromDrizzleTable(users)
// ✗ — 'id' is not excluded in the model entryconst model = fromDrizzleModel({ account: accounts, profile: profiles, billing: billingProfiles,})
// ✓ — database-owned fields are explicitly excludedconst base = fromDrizzleTable(users, { exclude: ['id', 'createdAt', 'updatedAt'] })
// ✓ — each entry can have its own excludeconst model = fromDrizzleModel({ account: { table: accounts, exclude: ['id', 'createdAt', 'updatedAt'] }, profile: { table: profiles, exclude: ['id'] }, billing: { table: billingProfiles, exclude: ['createdAt', 'updatedAt'] },})Options
Section titled “Options”All options are optional — the rule ships with sensible defaults:
| Option | Type | Default | Description |
|---|---|---|---|
fieldNames | string[] | ['id'] | Field names to treat as database-owned |
checkWriteCandidates | boolean | true | Flag database-owned fields in write candidate arguments |
checkDrizzleHelpers | boolean | true | Flag fromDrizzleTable/fromDrizzleModel calls missing excludes |
writeHelpers | string[] | ['checkCreate', 'checkPatch', 'checkDrizzleCreate', 'checkDrizzlePatch', 'checkDrizzleModelCreate', 'checkDrizzleModelPatch'] | Function names to check for write candidates |
drizzleHelpers | string[] | ['fromDrizzleTable', 'fromDrizzleModel'] | Function names to check for missing excludes |
writeHelpers and drizzleHelpers replace the defaults when provided. If you use a wrapper or alias for these functions, include the built-in helper names alongside your custom wrappers when you want both checked.
See also
Section titled “See also”umpire()construction-time checks — runtime validation that runs when the instance is created- Testing —
monkeyTest()for invariant testing across exhaustive input combinations @umpire/drizzle— the Drizzle adapter, includingcheckDrizzleCreate,checkDrizzlePatch, and the safer write workflow