|
import { z } from "zod"; |
|
|
|
// Define individual event schemas with discriminator and payload |
|
const UserCreatedEventSchema = z.object({ |
|
type: z.literal("user_created"), |
|
payload: z.object({ |
|
userId: z.uuid(), |
|
email: z.email(), |
|
createdAt: z.iso.datetime(), |
|
}), |
|
}); |
|
|
|
const UserUpdatedEventSchema = z.object({ |
|
type: z.literal("user_updated"), |
|
payload: z.object({ |
|
userId: z.uuid(), |
|
changes: z.record(z.string(), z.unknown()), |
|
updatedAt: z.iso.datetime(), |
|
}), |
|
}); |
|
|
|
const UserDeletedEventSchema = z.object({ |
|
type: z.literal("user_deleted"), |
|
payload: z.object({ |
|
userId: z.uuid(), |
|
deletedAt: z.iso.datetime(), |
|
}), |
|
}); |
|
|
|
const OrderPlacedEventSchema = z.object({ |
|
type: z.literal("order_placed"), |
|
payload: z.object({ |
|
orderId: z.uuid(), |
|
userId: z.uuid(), |
|
total: z.number().positive(), |
|
}), |
|
}); |
|
|
|
// Discriminated union of all events |
|
const EventSchema = z.discriminatedUnion("type", [ |
|
UserCreatedEventSchema, |
|
UserUpdatedEventSchema, |
|
UserDeletedEventSchema, |
|
OrderPlacedEventSchema, |
|
]); |
|
|
|
// Infer the Event type |
|
type Event = z.infer<typeof EventSchema>; |
|
|
|
// Extract the literal event type from Event |
|
type EventType = Event["type"]; |
|
|
|
// Type helper to extract the payload type for a given event type |
|
type PayloadForEvent<T extends EventType> = Extract<Event, { type: T }>["payload"]; |
|
|
|
// Extract all event type names from the discriminated union |
|
const eventTypeValues = EventSchema.options.map((schema) => schema.shape.type.value) as [EventType, ...EventType[]]; |
|
const allEventTypesSchema = z.enum(eventTypeValues); |
|
|
|
// Create a union schema of all payload schemas (automatically derived) |
|
const payloadSchemas = EventSchema.options.map((schema) => schema.shape.payload); |
|
const eventPayloadSchemas = z.union(payloadSchemas as [typeof payloadSchemas[0], typeof payloadSchemas[1], ...typeof payloadSchemas[number][]]); |
|
|
|
// --- Testing --- |
|
|
|
console.log("=== Testing Event Parsing ===\n"); |
|
|
|
// Valid event |
|
const validEvent: Event = { |
|
type: "user_created", |
|
payload: { |
|
userId: "550e8400-e29b-41d4-a716-446655440000", |
|
email: "[email protected]", |
|
createdAt: "2024-01-15T10:30:00Z", |
|
}, |
|
}; |
|
|
|
const eventResult = EventSchema.safeParse(validEvent); |
|
console.log("Valid event:", eventResult.success ? "PASS" : "FAIL"); |
|
|
|
// Invalid event (wrong type) |
|
const invalidEvent = { |
|
type: "unknown_event", |
|
data: "something", |
|
}; |
|
|
|
const invalidEventResult = EventSchema.safeParse(invalidEvent); |
|
console.log("Invalid event type:", !invalidEventResult.success ? "PASS" : "FAIL"); |
|
if (!invalidEventResult.success) { |
|
console.log(" Error:", invalidEventResult.error.issues[0].message); |
|
} |
|
|
|
console.log("\n=== Testing Event Type Name Validation ===\n"); |
|
|
|
// Test valid event type names |
|
const validTypes = ["user_created", "user_updated", "user_deleted", "order_placed"]; |
|
const invalidTypes = ["user_created_event", "unknown", "USER_CREATED", ""]; |
|
|
|
console.log("Valid type names:"); |
|
for (const typeName of validTypes) { |
|
const result = allEventTypesSchema.safeParse(typeName); |
|
console.log(` "${typeName}": ${result.success ? "VALID" : "INVALID"}`); |
|
} |
|
|
|
console.log("\nInvalid type names:"); |
|
for (const typeName of invalidTypes) { |
|
const result = allEventTypesSchema.safeParse(typeName); |
|
console.log(` "${typeName}": ${result.success ? "VALID" : "INVALID"}`); |
|
} |
|
|
|
// Helper function to check if a string is a valid event type |
|
function isValidEventType(value: string): value is EventType { |
|
return allEventTypesSchema.safeParse(value).success; |
|
} |
|
|
|
// Function to create and validate an event with type and payload |
|
// Uses PayloadForEvent for compile-time type checking AND Zod for runtime validation |
|
function createEvent<T extends EventType>(type: T, payload: PayloadForEvent<T>): Extract<Event, { type: T }> { |
|
// Validate the type at runtime |
|
const validatedType = allEventTypesSchema.parse(type); |
|
|
|
// Validate the payload at runtime |
|
const validatedPayload = eventPayloadSchemas.parse(payload); |
|
|
|
// Create the full event object with validated parts |
|
const eventData = { |
|
type: validatedType, |
|
payload: validatedPayload, |
|
}; |
|
|
|
// Parse and validate the complete event against the discriminated union |
|
return EventSchema.parse(eventData) as Extract<Event, { type: T }>; |
|
} |
|
|
|
console.log("\n=== Using Helper Function ===\n"); |
|
const testValue = "order_placed"; |
|
if (isValidEventType(testValue)) { |
|
console.log(`"${testValue}" is a valid EventType`); |
|
// TypeScript now knows testValue is EventType |
|
} |
|
|
|
console.log("\n=== Testing createEvent Function ===\n"); |
|
|
|
// Test 1: Valid user_created event |
|
try { |
|
const userCreated = createEvent("user_created", { |
|
userId: "550e8400-e29b-41d4-a716-446655440000", |
|
email: "[email protected]", |
|
createdAt: "2024-01-15T10:30:00Z", |
|
}); |
|
console.log("✓ Valid user_created event:", userCreated.type); |
|
} catch (error) { |
|
console.log("✗ Failed to create user_created event:", error); |
|
} |
|
|
|
// Test 2: Valid order_placed event |
|
try { |
|
const orderPlaced = createEvent("order_placed", { |
|
orderId: "123e4567-e89b-12d3-a456-426614174000", |
|
userId: "550e8400-e29b-41d4-a716-446655440000", |
|
total: 99.99, |
|
}); |
|
console.log("✓ Valid order_placed event:", orderPlaced.type); |
|
} catch (error) { |
|
console.log("✗ Failed to create order_placed event:", error); |
|
} |
|
|
|
// Test 3: Type mismatch - passing order_placed payload to user_created |
|
// This is now caught at COMPILE TIME! The @ts-expect-error suppresses the error for demonstration. |
|
try { |
|
// @ts-expect-error - orderId/total don't exist on user_created payload |
|
createEvent("user_created", { orderId: "123", total: 100 }); |
|
console.log("✗ Should have failed"); |
|
} catch { |
|
console.log("✓ Type mismatch: caught at compile time AND runtime"); |
|
} |
|
|
|
// Test 4: Missing required fields - also caught at compile time |
|
try { |
|
// @ts-expect-error - Missing 'changes' and 'updatedAt' fields for user_updated |
|
createEvent("user_updated", { userId: "550e8400-e29b-41d4-a716-446655440000" }); |
|
console.log("✗ Should have failed"); |
|
} catch { |
|
console.log("✓ Missing fields: caught at compile time AND runtime"); |
|
} |