Job Serialization
The serialization problem
A job lives in two worlds. In your TypeScript code, the payload is a typed object: { to: string, subject: string, orderId: string }. In the database, it is a TEXT column containing a JSON string: '{"to":"[email protected]","subject":"Order Confirmed","orderId":"order-1"}'.
Converting between these two forms is serialization (object → JSON string) and deserialization (JSON string → object). Getting this wrong causes subtle bugs: missing fields, wrong types, undefined values.
Enqueue: serialization
When enqueueing, the payload is serialized with JSON.stringify:
enqueue("send_email", {
to: "[email protected]",
subject: "Order Confirmed",
orderId: "order-1",
});
// Inside enqueue:
// JSON.stringify({ to: "[email protected]", subject: "Order Confirmed", orderId: "order-1" })
// → '{"to":"[email protected]","subject":"Order Confirmed","orderId":"order-1"}' JSON.stringify handles strings, numbers, booleans, arrays, and nested objects. It does NOT handle: Date objects (converted to strings), undefined values (dropped), Map and Set (converted to {}), BigInt (throws).
[!WARNING] If your payload contains a
Dateobject, it is serialized as a string:"2024-01-15T10:30:00.000Z". When you deserialize, it comes back as a string, not a Date. Always store dates as ISO strings in payloads to avoid this surprise.
Dequeue: deserialization
When the worker picks up a job, the payload is deserialized with JSON.parse:
const payload = JSON.parse(job.payload);
// payload is `any` — TypeScript does not know what is inside JSON.parse returns any. TypeScript provides no type safety. If the payload is missing a field or has the wrong type, you get a runtime error — not a compile-time error.
Adding type safety with Zod
Use Zod schemas to validate job payloads at deserialization time. This is the same pattern the URLs and HTTP course uses for request validation — applied to job payloads:
// src/job-schemas.ts
import { z } from "zod/v4";
export const SendEmailPayload = z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
});
export const GenerateInvoicePayload = z.object({
orderId: z.string(),
});
export const SyncInventoryPayload = z.object({
productId: z.string(),
quantity: z.number().int().positive(),
});
export type SendEmailPayload = z.infer<typeof SendEmailPayload>;
export type GenerateInvoicePayload = z.infer<typeof GenerateInvoicePayload>;
export type SyncInventoryPayload = z.infer<typeof SyncInventoryPayload>; Type-safe handlers
// src/job-handlers.ts
import { SendEmailPayload, GenerateInvoicePayload, SyncInventoryPayload } from "./job-schemas.js";
import { sendEmail } from "./services/email.js";
import { generatePDF } from "./services/pdf.js";
import { syncInventory } from "./services/inventory.js";
interface JobDefinition {
schema: z.ZodType;
handler: (payload: any) => Promise<void>;
}
const jobDefinitions: Record<string, JobDefinition> = {
send_email: {
schema: SendEmailPayload,
handler: async (payload: SendEmailPayload) => {
await sendEmail(payload.to, payload.subject, payload.body);
},
},
generate_invoice: {
schema: GenerateInvoicePayload,
handler: async (payload: GenerateInvoicePayload) => {
await generatePDF(payload.orderId);
},
},
sync_inventory: {
schema: SyncInventoryPayload,
handler: async (payload: SyncInventoryPayload) => {
await syncInventory(payload.productId, payload.quantity);
},
},
};
export async function processJob(job: Job): Promise<void> {
const definition = jobDefinitions[job.type];
if (!definition) {
throw new Error(`Unknown job type: ${job.type}`);
}
// Parse and validate the payload
const raw = JSON.parse(job.payload);
const result = definition.schema.safeParse(raw);
if (!result.success) {
throw new Error(`Invalid payload for ${job.type}: ${result.error.message}`);
}
await definition.handler(result.data);
} If the payload is invalid (missing to, orderId is a number instead of a string), safeParse fails with a clear error message. The job fails with a descriptive error instead of a cryptic TypeError deep in the handler.
Type-safe enqueue
You can also validate at enqueue time to catch mistakes early:
export function enqueue<T>(
type: string,
payload: T,
options: { priority?: number; scheduledAt?: string; maxAttempts?: number } = {},
): string {
// Validate that the job type exists
if (!jobDefinitions[type]) {
throw new Error(`Unknown job type: ${type}`);
}
// Validate the payload matches the schema
const result = jobDefinitions[type].schema.safeParse(payload);
if (!result.success) {
throw new Error(`Invalid payload for ${type}: ${result.error.message}`);
}
const id = crypto.randomUUID();
db.prepare(
`
INSERT INTO jobs (id, type, payload, priority, max_attempts, scheduled_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
).run(
id,
type,
JSON.stringify(result.data),
options.priority ?? 0,
options.maxAttempts ?? 5,
options.scheduledAt ?? new Date().toISOString(),
);
return id;
} Now a typo like enqueue("send_email", { too: "[email protected]" }) fails immediately at enqueue time — not 30 seconds later when the worker tries to process it.
Exercises
Exercise 1: Enqueue a job with a Date object in the payload. Deserialize it. Verify it is a string, not a Date.
Exercise 2: Add Zod schemas for all job types. Enqueue a job with a missing field. Verify it fails at enqueue time.
Exercise 3: Enqueue a valid job. Process it in the worker. Verify the Zod schema validates the payload before the handler runs.
Why validate job payloads with Zod instead of just using JSON.parse?