Skip to content

Instantly share code, notes, and snippets.

@SelrahcD
Last active November 25, 2025 11:40
Show Gist options
  • Select an option

  • Save SelrahcD/ee5c05a2f7141e42b38b7557b618ccb1 to your computer and use it in GitHub Desktop.

Select an option

Save SelrahcD/ee5c05a2f7141e42b38b7557b618ccb1 to your computer and use it in GitHub Desktop.
Zod discriminated union with dynamic event type extraction (v3/v4 compatible)
node_modules/
dist/

Zod + TypeScript Playground

A minimal playground for experimenting with Zod schema validation and TypeScript.

Setup

npm install

Usage

# Run the playground
npm run dev

# Type check without emitting
npm run typecheck

# Build to dist/
npm run build

Features

  • Discriminated union event schema
  • Dynamic extraction of event type names from the union
  • Type-safe validation of event type strings
  • Compatible with Zod v3 and v4

Example

import { z } from "zod";

// Discriminated union
const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("user_created"), userId: z.string() }),
  z.object({ type: z.literal("order_placed"), orderId: z.string() }),
]);

// Dynamically extract all event type names
const eventTypeValues = EventSchema.options.map(
  (schema) => schema.shape.type.value
) as [string, ...string[]];

const allEventTypesSchema = z.enum(eventTypeValues);

// Validate event type strings
allEventTypesSchema.parse("user_created"); // OK
allEventTypesSchema.parse("unknown"); // Throws
{
"name": "zod",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "ts-node src/playground.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@types/node": "^24.10.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"zod": "^4.1.12"
}
}
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");
}
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment