Skip to content

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.

ESLint≥ 9.0.0
OxlintJS plugins (jsPlugins)
Config formatFlat config only (eslint.config.js) — legacy .eslintrc is not supported
NodeWhatever 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.

Terminal window
npm install --save-dev @umpire/eslint-plugin

Add 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',
},
},
]

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.

RuleSeverityWhat it catches
no-unknown-fieldswarnField names in rules that aren’t declared in fields
no-inline-umpire-initwarnumpire() called inside a component or hook body without useMemo
no-write-owned-fieldsopt-inDatabase-owned fields (like id) submitted through write candidates or missing from Drizzle excludes
no-self-disableerrorA field listed as both source and target of disables()
no-contradicting-ruleserrorrequires/disables pairs that make a field permanently unavailable
no-circular-requireserrorCircular 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.


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 fields
const ump = umpire({
fields: { mode: {}, details: {}, submit: {} },
rules: [
requires('submitt', 'mode'), // typo
],
})
// ✓
const ump = umpire({
fields: { mode: {}, details: {}, submit: {} },
rules: [
requires('submit', 'mode'),
],
})

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 render
function CheckoutForm() {
const ump = umpire({
fields: { card: {}, billing: {}, submit: {} },
rules: [requires('submit', 'card')],
})
const { check } = useUmpire(ump, values)
// ...
}
// ✓ — defined once at module scope
const ump = umpire({
fields: { card: {}, billing: {}, submit: {} },
rules: [requires('submit', 'card')],
})
function CheckoutForm() {
const { check } = useUmpire(ump, values)
// ...
}
// ✓ — or memoized if it depends on props
function CheckoutForm({ requireBilling }: Props) {
const ump = useMemo(() => umpire({
fields: { card: {}, billing: {}, submit: {} },
rules: [
requires('submit', 'card'),
...(requireBilling ? [requires('submit', 'billing')] : []),
],
}), [requireBilling])
// ...
}

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 itself
const ump = umpire({
fields: { promo: {}, discount: {} },
rules: [
disables('promo', ['promo', 'discount']),
],
})
// ✓
const ump = umpire({
fields: { promo: {}, discount: {} },
rules: [
disables('promo', ['discount']),
],
})

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 dependency
const ump = umpire({
fields: { plan: {}, annual: {}, discount: {} },
rules: [
requires('annual', 'plan'),
disables('plan', ['discount']),
],
})

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 other
const ump = umpire({
fields: { a: {}, b: {}, c: {} },
rules: [
requires('a', 'b'),
requires('b', 'a'),
],
})
// ✗ — three-node cycle: a → b → c → a
const ump = umpire({
fields: { a: {}, b: {}, c: {} },
rules: [
requires('a', 'b'),
requires('b', 'c'),
requires('c', 'a'),
],
})
// ✓ — linear dependency, no cycle
const ump = umpire({
fields: { a: {}, b: {}, c: {} },
rules: [
requires('a', 'b'),
requires('b', 'c'),
],
})

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() and checkPatch() calls that match the configured field names (defaults to id).
  • Drizzle helpers — flags fromDrizzleTable() and fromDrizzleModel() calls that don’t explicitly exclude those fields via the exclude option.

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'],
}],
},
},
]

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 field
const result = checkCreate(userUmp, {
id: 123,
email: 'alex@example.com',
companyName: 'Acme',
})
// ✓ — database-owned fields are absent
const result = checkCreate(userUmp, {
email: 'alex@example.com',
companyName: 'Acme',
})

Drizzle helpers — the rule flags calls that omit the exclude option:

// ✗ — 'id' is not excluded
const base = fromDrizzleTable(users)
// ✗ — 'id' is not excluded in the model entry
const model = fromDrizzleModel({
account: accounts,
profile: profiles,
billing: billingProfiles,
})
// ✓ — database-owned fields are explicitly excluded
const base = fromDrizzleTable(users, { exclude: ['id', 'createdAt', 'updatedAt'] })
// ✓ — each entry can have its own exclude
const model = fromDrizzleModel({
account: { table: accounts, exclude: ['id', 'createdAt', 'updatedAt'] },
profile: { table: profiles, exclude: ['id'] },
billing: { table: billingProfiles, exclude: ['createdAt', 'updatedAt'] },
})

All options are optional — the rule ships with sensible defaults:

OptionTypeDefaultDescription
fieldNamesstring[]['id']Field names to treat as database-owned
checkWriteCandidatesbooleantrueFlag database-owned fields in write candidate arguments
checkDrizzleHelpersbooleantrueFlag fromDrizzleTable/fromDrizzleModel calls missing excludes
writeHelpersstring[]['checkCreate', 'checkPatch', 'checkDrizzleCreate', 'checkDrizzlePatch', 'checkDrizzleModelCreate', 'checkDrizzleModelPatch']Function names to check for write candidates
drizzleHelpersstring[]['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.


  • umpire() construction-time checks — runtime validation that runs when the instance is created
  • TestingmonkeyTest() for invariant testing across exhaustive input combinations
  • @umpire/drizzle — the Drizzle adapter, including checkDrizzleCreate, checkDrizzlePatch, and the safer write workflow