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/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.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 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..9f567d1b --- /dev/null +++ b/packages/schemas/lib/utils/messageTypeUtils.types.spec.ts @@ -0,0 +1,71 @@ +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 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('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<'live' | 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'],