From ab7d301efbff46270b5a9f4955f0f00977b6e676 Mon Sep 17 00:00:00 2001 From: CarlosGamero Date: Thu, 11 Jun 2026 16:57:00 +0200 Subject: [PATCH 1/4] fix: resolve consumer message schemas to the schema output type Consumers receive messages that have already been parsed by the consumer schema, so ConsumerMessageSchema and AllConsumerMessageSchemas should resolve to z.output rather than z.input. For schemas without transforms both types are identical, so this changes nothing. They diverge once a schema uses transforms or preprocess (e.g. a field that tolerantly drops unknown enum values): there z.input degrades to unknown and breaks typing in message handlers, while the handler actually receives the parsed output. Publisher-side types intentionally stay z.input, since publishers pass the raw payload that the schema parses on emit. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/schemas/lib/utils/messageTypeUtils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/schemas/lib/utils/messageTypeUtils.ts b/packages/schemas/lib/utils/messageTypeUtils.ts index 68dee11d..42efd1c6 100644 --- a/packages/schemas/lib/utils/messageTypeUtils.ts +++ b/packages/schemas/lib/utils/messageTypeUtils.ts @@ -3,9 +3,10 @@ import type { z } from 'zod/v4' import type { CommonEventDefinition } from '../events/eventTypes.ts' /** - * Resolves schema of a consumer message for a given event definition + * Resolves schema of a consumer message for a given event definition. + * Consumers receive messages already parsed by the consumer schema, hence the output type. */ -export type ConsumerMessageSchema = z.input< +export type ConsumerMessageSchema = z.output< MessageDefinitionType['consumerSchema'] > @@ -23,7 +24,8 @@ export type AllPublisherMessageSchemas /** - * Resolves schema of all possible consumer messages for a given list of event definitions + * Resolves schema of all possible consumer messages for a given list of event definitions. + * Consumers receive messages already parsed by the consumer schema, hence the output type. */ export type AllConsumerMessageSchemas = - z.input + z.output From 47ede480d1350dfd43d99e5d4316de694122e7ac Mon Sep 17 00:00:00 2001 From: CarlosGamero Date: Thu, 11 Jun 2026 17:12:53 +0200 Subject: [PATCH 2/4] Adding type check tests to message type utils --- .../lib/utils/messageTypeUtils.types.spec.ts | 78 +++++++++++++++++++ packages/schemas/vitest.config.ts | 4 + 2 files changed, 82 insertions(+) create mode 100644 packages/schemas/lib/utils/messageTypeUtils.types.spec.ts diff --git a/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts new file mode 100644 index 00000000..54a1cfea --- /dev/null +++ b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts @@ -0,0 +1,78 @@ +import { expectTypeOf } from 'vitest' +import { z } from 'zod/v4' +import type { CommonEventDefinition } from '../events/eventTypes.ts' +import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts' +import type { + AllConsumerMessageSchemas, + ConsumerMessageSchema, + PublisherMessageSchema, +} from './messageTypeUtils.ts' + +const SUPPORTED_MODES = ['status', 'value'] as const + +const myEvents = { + plainEvent: { + ...enrichMessageSchemaWithBase('entity.created', z.object({ name: z.string() })), + }, + transformingEvent: { + ...enrichMessageSchemaWithBase( + 'entity.updated', + z.object({ + // Forward-compatible field: unknown values are dropped instead of failing validation + mode: z.preprocess( + (value) => + typeof value === 'string' && (SUPPORTED_MODES as readonly string[]).includes(value) + ? value + : undefined, + z.enum(SUPPORTED_MODES).optional(), + ), + }), + ), + }, +} as const satisfies Record + +describe('messageTypeUtils', () => { + describe('ConsumerMessageSchema', () => { + it('resolves to the parsed message type for transform-free schemas', () => { + type ConsumerMessage = ConsumerMessageSchema + + expectTypeOf().toEqualTypeOf<'entity.created'>() + expectTypeOf().toEqualTypeOf() + }) + + it('resolves transformed fields to their output type, not their input type', () => { + type ConsumerMessage = ConsumerMessageSchema + + // Consumers receive messages already parsed by the consumer schema, so the + // field is the preprocess output, not unknown (the input of any preprocess) + expectTypeOf().toEqualTypeOf< + 'status' | 'value' | undefined + >() + }) + }) + + describe('PublisherMessageSchema', () => { + it('resolves to the raw (pre-parse) message type', () => { + type PublisherMessage = PublisherMessageSchema + + expectTypeOf().toEqualTypeOf() + }) + + it('keeps the input type for transformed fields', () => { + type PublisherMessage = PublisherMessageSchema + + // Publishers pass the raw payload that the schema parses on emit + expectTypeOf().toBeUnknown() + }) + }) + + describe('AllConsumerMessageSchemas', () => { + it('resolves to the union of parsed message types', () => { + type SupportedMessages = AllConsumerMessageSchemas< + [typeof myEvents.plainEvent, typeof myEvents.transformingEvent] + > + + expectTypeOf().toEqualTypeOf<'entity.created' | 'entity.updated'>() + }) + }) +}) diff --git a/packages/schemas/vitest.config.ts b/packages/schemas/vitest.config.ts index af9944ef..182fdaab 100644 --- a/packages/schemas/vitest.config.ts +++ b/packages/schemas/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ watch: false, mockReset: true, pool: 'threads', + typecheck: { + enabled: true, + include: ['**/*.types.spec.ts'], + }, coverage: { provider: 'v8', include: ['lib/**/*.ts'], From e62ea8313b73ce5b4ddcdcf44ac083db50daf481 Mon Sep 17 00:00:00 2001 From: CarlosGamero Date: Thu, 11 Jun 2026 17:21:00 +0200 Subject: [PATCH 3/4] Fixing one more typing issue --- packages/schemas/lib/events/eventTypes.ts | 3 ++- .../lib/utils/messageTypeUtils.types.spec.ts | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/schemas/lib/events/eventTypes.ts b/packages/schemas/lib/events/eventTypes.ts index 53856663..fd533644 100644 --- a/packages/schemas/lib/events/eventTypes.ts +++ b/packages/schemas/lib/events/eventTypes.ts @@ -32,7 +32,8 @@ export type CommonEventDefinition = { tags?: readonly string[] // Free-form tags for the event } -export type CommonEventDefinitionConsumerSchemaType = z.input< +// Consumers receive messages already parsed by the consumer schema, hence the output type. +export type CommonEventDefinitionConsumerSchemaType = z.output< T['consumerSchema'] > diff --git a/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts index 54a1cfea..66e31a1e 100644 --- a/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts +++ b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts @@ -1,6 +1,9 @@ import { expectTypeOf } from 'vitest' import { z } from 'zod/v4' -import type { CommonEventDefinition } from '../events/eventTypes.ts' +import type { + CommonEventDefinition, + CommonEventDefinitionConsumerSchemaType, +} from '../events/eventTypes.ts' import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts' import type { AllConsumerMessageSchemas, @@ -66,6 +69,19 @@ describe('messageTypeUtils', () => { }) }) + describe('CommonEventDefinitionConsumerSchemaType', () => { + it('resolves transformed fields to their output type, like ConsumerMessageSchema', () => { + type ConsumerMessage = CommonEventDefinitionConsumerSchemaType< + typeof myEvents.transformingEvent + > + + // DomainEventEmitter hands handlers the event parsed by the schema + expectTypeOf().toEqualTypeOf< + 'status' | 'value' | undefined + >() + }) + }) + describe('AllConsumerMessageSchemas', () => { it('resolves to the union of parsed message types', () => { type SupportedMessages = AllConsumerMessageSchemas< From d097e5e3ed54cb59d7c36e04ec30276c4cfc74f9 Mon Sep 17 00:00:00 2001 From: CarlosGamero Date: Thu, 11 Jun 2026 17:31:01 +0200 Subject: [PATCH 4/4] Adding type tests --- .../lib/events/eventTypes.types.spec.ts | 75 +++++++++++++++++++ .../lib/utils/messageTypeUtils.types.spec.ts | 31 +------- 2 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 packages/schemas/lib/events/eventTypes.types.spec.ts diff --git a/packages/schemas/lib/events/eventTypes.types.spec.ts b/packages/schemas/lib/events/eventTypes.types.spec.ts new file mode 100644 index 00000000..df68e7f4 --- /dev/null +++ b/packages/schemas/lib/events/eventTypes.types.spec.ts @@ -0,0 +1,75 @@ +import { expectTypeOf } from 'vitest' +import { z } from 'zod/v4' +import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts' +import type { + AnyEventHandler, + CommonEventDefinition, + CommonEventDefinitionConsumerSchemaType, + CommonEventDefinitionPublisherSchemaType, + SingleEventHandler, +} from './eventTypes.ts' + +const myEvents = { + plainEvent: { + ...enrichMessageSchemaWithBase('entity.created', z.object({ name: z.string() })), + }, + transformingEvent: { + ...enrichMessageSchemaWithBase( + 'entity.updated', + z.object({ + // Forward-compatible field: unknown values are dropped instead of failing validation + mode: z.preprocess( + (value) => (value === 'live' ? value : undefined), + z.literal('live').optional(), + ), + }), + ), + }, +} as const satisfies Record + +describe('eventTypes', () => { + describe('CommonEventDefinitionConsumerSchemaType', () => { + it('resolves transformed fields to their output type, not their input type', () => { + type ConsumerMessage = CommonEventDefinitionConsumerSchemaType< + typeof myEvents.transformingEvent + > + + // DomainEventEmitter hands handlers the event parsed by the schema + expectTypeOf().toEqualTypeOf<'live' | undefined>() + }) + }) + + describe('CommonEventDefinitionPublisherSchemaType', () => { + it('keeps the input type for transformed fields', () => { + type PublisherMessage = CommonEventDefinitionPublisherSchemaType< + typeof myEvents.transformingEvent + > + + // Publishers pass the raw payload that the schema parses on emit + expectTypeOf().toBeUnknown() + }) + }) + + describe('SingleEventHandler', () => { + it('receives the parsed event', () => { + type Handler = SingleEventHandler<[typeof myEvents.transformingEvent], 'entity.updated'> + type HandledEvent = Parameters[0] + + expectTypeOf().toEqualTypeOf<'live' | undefined>() + }) + }) + + describe('AnyEventHandler', () => { + it('receives the parsed event union', () => { + type Handler = AnyEventHandler< + [typeof myEvents.plainEvent, typeof myEvents.transformingEvent] + > + type HandledEvent = Parameters[0] + + expectTypeOf().toEqualTypeOf<'entity.created' | 'entity.updated'>() + expectTypeOf< + Extract['payload']['mode'] + >().toEqualTypeOf<'live' | undefined>() + }) + }) +}) diff --git a/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts index 66e31a1e..9f567d1b 100644 --- a/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts +++ b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts @@ -1,9 +1,6 @@ import { expectTypeOf } from 'vitest' import { z } from 'zod/v4' -import type { - CommonEventDefinition, - CommonEventDefinitionConsumerSchemaType, -} from '../events/eventTypes.ts' +import type { CommonEventDefinition } from '../events/eventTypes.ts' import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts' import type { AllConsumerMessageSchemas, @@ -11,8 +8,6 @@ import type { PublisherMessageSchema, } from './messageTypeUtils.ts' -const SUPPORTED_MODES = ['status', 'value'] as const - const myEvents = { plainEvent: { ...enrichMessageSchemaWithBase('entity.created', z.object({ name: z.string() })), @@ -23,11 +18,8 @@ const myEvents = { z.object({ // Forward-compatible field: unknown values are dropped instead of failing validation mode: z.preprocess( - (value) => - typeof value === 'string' && (SUPPORTED_MODES as readonly string[]).includes(value) - ? value - : undefined, - z.enum(SUPPORTED_MODES).optional(), + (value) => (value === 'live' ? value : undefined), + z.literal('live').optional(), ), }), ), @@ -48,9 +40,7 @@ describe('messageTypeUtils', () => { // Consumers receive messages already parsed by the consumer schema, so the // field is the preprocess output, not unknown (the input of any preprocess) - expectTypeOf().toEqualTypeOf< - 'status' | 'value' | undefined - >() + expectTypeOf().toEqualTypeOf<'live' | undefined>() }) }) @@ -69,19 +59,6 @@ describe('messageTypeUtils', () => { }) }) - describe('CommonEventDefinitionConsumerSchemaType', () => { - it('resolves transformed fields to their output type, like ConsumerMessageSchema', () => { - type ConsumerMessage = CommonEventDefinitionConsumerSchemaType< - typeof myEvents.transformingEvent - > - - // DomainEventEmitter hands handlers the event parsed by the schema - expectTypeOf().toEqualTypeOf< - 'status' | 'value' | undefined - >() - }) - }) - describe('AllConsumerMessageSchemas', () => { it('resolves to the union of parsed message types', () => { type SupportedMessages = AllConsumerMessageSchemas<