Make limiter bindings truly type-safe with a context-first defineJob API #2

Closed
opened 2026-04-19 18:46:29 +02:00 by AzSiAz · 0 comments
Owner

Problem

The current limiter API is only partially type-safe.

Today jobs are authored in a shape like:

const sendEmail = defineJob({
  key: "sendEmail",
  queue: "outbound",
  input: z.object({
    tenantId: z.string(),
    mailboxId: z.string().optional(),
    to: z.string().email(),
  }),
  limits: [
    limiters.tenant(input => ({
      tenantId: input.tenantId,
    })),
    limiters.mailbox.when(
      input => input.mailboxId !== undefined,
      input => ({
        mailboxId: input.mailboxId,
      }),
    ),
  ],
})

This gives good checking for the limiter payload shape, but TypeScript cannot infer the limiter selector input from the sibling input schema field inside the same object literal.

As a result:

  • dedup selectors infer the job input correctly
  • limiter selectors do not get the same level of inference
  • consumers sometimes need explicit annotations for limiter selector inputs

This is a TypeScript inference limitation caused by the current API shape, not a runtime bug.

Goal

Make limiter bindings truly type-safe by ensuring that:

  1. limiter selector input is inferred from the job input schema
  2. limiter selector output is checked against the limiter key schema
  3. conditional limiter predicates also see the inferred job input type

Proposed API

Move to a context-first defineJob signature:

const sendEmail = defineJob({ limiters }, {
  key: "sendEmail",
  queue: "outbound",
  input: z.object({
    tenantId: z.string(),
    mailboxId: z.string().optional(),
    to: z.string().email(),
  }),
  limits: ({ L }) => [
    L.tenant(input => ({
      tenantId: input.tenantId,
    })),
    L.mailbox.when(
      input => input.mailboxId !== undefined,
      input => ({
        mailboxId: input.mailboxId,
      }),
    ),
  ],
})

Why this is better

This gives defineJob everything it needs up front:

  • the limiter registry
  • the job input schema
  • a place to create job-input-bound limiter helpers

So L can be typed with:

  • selector input = inferred job input
  • selector output = inferred limiter payload

This solves the missing bridge in the current API.

Expected type behavior

This should be valid:

L.tenant(input => ({
  tenantId: input.tenantId,
}))

This should fail because the job input is wrong:

L.tenant(input => ({
  tenantId: input.doesNotExist,
}))

This should fail because the limiter payload shape is wrong:

L.tenant(input => ({
  mailboxId: input.mailboxId,
}))

This should also fail because the limiter payload value type is wrong:

L.tenant(input => ({
  tenantId: 123,
}))

Suggested implementation model

Conceptually:

  1. defineJob() accepts context first:
    • { limiters }
  2. defineJob() accepts the job spec second
  3. infer TInput from input
  4. derive job-input-bound limiter helpers:
    • L.tenant
    • L.mailbox.when
  5. evaluate the limits callback with those helpers
  6. store the resolved limit bindings on the final job spec

Suggested signature

Something in this direction:

defineJob(
  { limiters },
  {
    key: "sendEmail",
    queue: "outbound",
    input: z.object(...),
    result: z.object(...),
    dedup: { ... },
    limits: ({ L }) => [
      L.tenant(...),
      L.mailbox.when(...),
    ],
  },
)

Scope

This should be treated as a breaking API improvement.

It is not a runtime correctness issue. The current package works. This change is about making the authoring API match the type-safety expectations set by the rest of the library.

Acceptance criteria

  • limiter selector input is inferred from the job input schema
  • limiter selector output is checked against the limiter key schema
  • .when(...) predicate input is inferred from the job input schema
  • existing runtime limiter behavior remains unchanged
  • new tsd coverage proves the improved inference
### Problem The current limiter API is only partially type-safe. Today jobs are authored in a shape like: ```ts const sendEmail = defineJob({ key: "sendEmail", queue: "outbound", input: z.object({ tenantId: z.string(), mailboxId: z.string().optional(), to: z.string().email(), }), limits: [ limiters.tenant(input => ({ tenantId: input.tenantId, })), limiters.mailbox.when( input => input.mailboxId !== undefined, input => ({ mailboxId: input.mailboxId, }), ), ], }) ``` This gives good checking for the limiter payload shape, but TypeScript cannot infer the limiter selector input from the sibling `input` schema field inside the same object literal. As a result: - dedup selectors infer the job input correctly - limiter selectors do **not** get the same level of inference - consumers sometimes need explicit annotations for limiter selector inputs This is a TypeScript inference limitation caused by the current API shape, not a runtime bug. ### Goal Make limiter bindings truly type-safe by ensuring that: 1. limiter selector input is inferred from the job `input` schema 2. limiter selector output is checked against the limiter key schema 3. conditional limiter predicates also see the inferred job input type ### Proposed API Move to a context-first `defineJob` signature: ```ts const sendEmail = defineJob({ limiters }, { key: "sendEmail", queue: "outbound", input: z.object({ tenantId: z.string(), mailboxId: z.string().optional(), to: z.string().email(), }), limits: ({ L }) => [ L.tenant(input => ({ tenantId: input.tenantId, })), L.mailbox.when( input => input.mailboxId !== undefined, input => ({ mailboxId: input.mailboxId, }), ), ], }) ``` ### Why this is better This gives `defineJob` everything it needs up front: - the limiter registry - the job input schema - a place to create job-input-bound limiter helpers So `L` can be typed with: - selector input = inferred job input - selector output = inferred limiter payload This solves the missing bridge in the current API. ### Expected type behavior This should be valid: ```ts L.tenant(input => ({ tenantId: input.tenantId, })) ``` This should fail because the job input is wrong: ```ts L.tenant(input => ({ tenantId: input.doesNotExist, })) ``` This should fail because the limiter payload shape is wrong: ```ts L.tenant(input => ({ mailboxId: input.mailboxId, })) ``` This should also fail because the limiter payload value type is wrong: ```ts L.tenant(input => ({ tenantId: 123, })) ``` ### Suggested implementation model Conceptually: 1. `defineJob()` accepts context first: - `{ limiters }` 2. `defineJob()` accepts the job spec second 3. infer `TInput` from `input` 4. derive job-input-bound limiter helpers: - `L.tenant` - `L.mailbox.when` 5. evaluate the `limits` callback with those helpers 6. store the resolved limit bindings on the final job spec ### Suggested signature Something in this direction: ```ts defineJob( { limiters }, { key: "sendEmail", queue: "outbound", input: z.object(...), result: z.object(...), dedup: { ... }, limits: ({ L }) => [ L.tenant(...), L.mailbox.when(...), ], }, ) ``` ### Scope This should be treated as a breaking API improvement. It is not a runtime correctness issue. The current package works. This change is about making the authoring API match the type-safety expectations set by the rest of the library. ### Acceptance criteria - limiter selector input is inferred from the job input schema - limiter selector output is checked against the limiter key schema - `.when(...)` predicate input is inferred from the job input schema - existing runtime limiter behavior remains unchanged - new `tsd` coverage proves the improved inference
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AzSiAz/jobs-kit#2
No description provided.