Skip to content

Commit 547ea16

Browse files
CarlosGameroclaude
andauthored
fix: resolve consumer message schemas to the schema output type (#472)
* 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) <noreply@anthropic.com> * Adding type check tests to message type utils * Fixing one more typing issue * Adding type tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 258b8e5 commit 547ea16

5 files changed

Lines changed: 158 additions & 5 deletions

File tree

packages/schemas/lib/events/eventTypes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export type CommonEventDefinition = {
3232
tags?: readonly string[] // Free-form tags for the event
3333
}
3434

35-
export type CommonEventDefinitionConsumerSchemaType<T extends CommonEventDefinition> = z.input<
35+
// Consumers receive messages already parsed by the consumer schema, hence the output type.
36+
export type CommonEventDefinitionConsumerSchemaType<T extends CommonEventDefinition> = z.output<
3637
T['consumerSchema']
3738
>
3839

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expectTypeOf } from 'vitest'
2+
import { z } from 'zod/v4'
3+
import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts'
4+
import type {
5+
AnyEventHandler,
6+
CommonEventDefinition,
7+
CommonEventDefinitionConsumerSchemaType,
8+
CommonEventDefinitionPublisherSchemaType,
9+
SingleEventHandler,
10+
} from './eventTypes.ts'
11+
12+
const myEvents = {
13+
plainEvent: {
14+
...enrichMessageSchemaWithBase('entity.created', z.object({ name: z.string() })),
15+
},
16+
transformingEvent: {
17+
...enrichMessageSchemaWithBase(
18+
'entity.updated',
19+
z.object({
20+
// Forward-compatible field: unknown values are dropped instead of failing validation
21+
mode: z.preprocess(
22+
(value) => (value === 'live' ? value : undefined),
23+
z.literal('live').optional(),
24+
),
25+
}),
26+
),
27+
},
28+
} as const satisfies Record<string, CommonEventDefinition>
29+
30+
describe('eventTypes', () => {
31+
describe('CommonEventDefinitionConsumerSchemaType', () => {
32+
it('resolves transformed fields to their output type, not their input type', () => {
33+
type ConsumerMessage = CommonEventDefinitionConsumerSchemaType<
34+
typeof myEvents.transformingEvent
35+
>
36+
37+
// DomainEventEmitter hands handlers the event parsed by the schema
38+
expectTypeOf<ConsumerMessage['payload']['mode']>().toEqualTypeOf<'live' | undefined>()
39+
})
40+
})
41+
42+
describe('CommonEventDefinitionPublisherSchemaType', () => {
43+
it('keeps the input type for transformed fields', () => {
44+
type PublisherMessage = CommonEventDefinitionPublisherSchemaType<
45+
typeof myEvents.transformingEvent
46+
>
47+
48+
// Publishers pass the raw payload that the schema parses on emit
49+
expectTypeOf<PublisherMessage['payload']['mode']>().toBeUnknown()
50+
})
51+
})
52+
53+
describe('SingleEventHandler', () => {
54+
it('receives the parsed event', () => {
55+
type Handler = SingleEventHandler<[typeof myEvents.transformingEvent], 'entity.updated'>
56+
type HandledEvent = Parameters<Handler['handleEvent']>[0]
57+
58+
expectTypeOf<HandledEvent['payload']['mode']>().toEqualTypeOf<'live' | undefined>()
59+
})
60+
})
61+
62+
describe('AnyEventHandler', () => {
63+
it('receives the parsed event union', () => {
64+
type Handler = AnyEventHandler<
65+
[typeof myEvents.plainEvent, typeof myEvents.transformingEvent]
66+
>
67+
type HandledEvent = Parameters<Handler['handleEvent']>[0]
68+
69+
expectTypeOf<HandledEvent['type']>().toEqualTypeOf<'entity.created' | 'entity.updated'>()
70+
expectTypeOf<
71+
Extract<HandledEvent, { type: 'entity.updated' }>['payload']['mode']
72+
>().toEqualTypeOf<'live' | undefined>()
73+
})
74+
})
75+
})

packages/schemas/lib/utils/messageTypeUtils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import type { z } from 'zod/v4'
33
import type { CommonEventDefinition } from '../events/eventTypes.ts'
44

55
/**
6-
* Resolves schema of a consumer message for a given event definition
6+
* Resolves schema of a consumer message for a given event definition.
7+
* Consumers receive messages already parsed by the consumer schema, hence the output type.
78
*/
8-
export type ConsumerMessageSchema<MessageDefinitionType extends CommonEventDefinition> = z.input<
9+
export type ConsumerMessageSchema<MessageDefinitionType extends CommonEventDefinition> = z.output<
910
MessageDefinitionType['consumerSchema']
1011
>
1112

@@ -23,7 +24,8 @@ export type AllPublisherMessageSchemas<MessageDefinitionTypes extends CommonEven
2324
z.input<MessageDefinitionTypes[number]['publisherSchema']>
2425

2526
/**
26-
* Resolves schema of all possible consumer messages for a given list of event definitions
27+
* Resolves schema of all possible consumer messages for a given list of event definitions.
28+
* Consumers receive messages already parsed by the consumer schema, hence the output type.
2729
*/
2830
export type AllConsumerMessageSchemas<MessageDefinitionTypes extends CommonEventDefinition[]> =
29-
z.input<MessageDefinitionTypes[number]['consumerSchema']>
31+
z.output<MessageDefinitionTypes[number]['consumerSchema']>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expectTypeOf } from 'vitest'
2+
import { z } from 'zod/v4'
3+
import type { CommonEventDefinition } from '../events/eventTypes.ts'
4+
import { enrichMessageSchemaWithBase } from '../messages/baseMessageSchemas.ts'
5+
import type {
6+
AllConsumerMessageSchemas,
7+
ConsumerMessageSchema,
8+
PublisherMessageSchema,
9+
} from './messageTypeUtils.ts'
10+
11+
const myEvents = {
12+
plainEvent: {
13+
...enrichMessageSchemaWithBase('entity.created', z.object({ name: z.string() })),
14+
},
15+
transformingEvent: {
16+
...enrichMessageSchemaWithBase(
17+
'entity.updated',
18+
z.object({
19+
// Forward-compatible field: unknown values are dropped instead of failing validation
20+
mode: z.preprocess(
21+
(value) => (value === 'live' ? value : undefined),
22+
z.literal('live').optional(),
23+
),
24+
}),
25+
),
26+
},
27+
} as const satisfies Record<string, CommonEventDefinition>
28+
29+
describe('messageTypeUtils', () => {
30+
describe('ConsumerMessageSchema', () => {
31+
it('resolves to the parsed message type for transform-free schemas', () => {
32+
type ConsumerMessage = ConsumerMessageSchema<typeof myEvents.plainEvent>
33+
34+
expectTypeOf<ConsumerMessage['type']>().toEqualTypeOf<'entity.created'>()
35+
expectTypeOf<ConsumerMessage['payload']['name']>().toEqualTypeOf<string>()
36+
})
37+
38+
it('resolves transformed fields to their output type, not their input type', () => {
39+
type ConsumerMessage = ConsumerMessageSchema<typeof myEvents.transformingEvent>
40+
41+
// Consumers receive messages already parsed by the consumer schema, so the
42+
// field is the preprocess output, not unknown (the input of any preprocess)
43+
expectTypeOf<ConsumerMessage['payload']['mode']>().toEqualTypeOf<'live' | undefined>()
44+
})
45+
})
46+
47+
describe('PublisherMessageSchema', () => {
48+
it('resolves to the raw (pre-parse) message type', () => {
49+
type PublisherMessage = PublisherMessageSchema<typeof myEvents.plainEvent>
50+
51+
expectTypeOf<PublisherMessage['payload']['name']>().toEqualTypeOf<string>()
52+
})
53+
54+
it('keeps the input type for transformed fields', () => {
55+
type PublisherMessage = PublisherMessageSchema<typeof myEvents.transformingEvent>
56+
57+
// Publishers pass the raw payload that the schema parses on emit
58+
expectTypeOf<PublisherMessage['payload']['mode']>().toBeUnknown()
59+
})
60+
})
61+
62+
describe('AllConsumerMessageSchemas', () => {
63+
it('resolves to the union of parsed message types', () => {
64+
type SupportedMessages = AllConsumerMessageSchemas<
65+
[typeof myEvents.plainEvent, typeof myEvents.transformingEvent]
66+
>
67+
68+
expectTypeOf<SupportedMessages['type']>().toEqualTypeOf<'entity.created' | 'entity.updated'>()
69+
})
70+
})
71+
})

packages/schemas/vitest.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export default defineConfig({
77
watch: false,
88
mockReset: true,
99
pool: 'threads',
10+
typecheck: {
11+
enabled: true,
12+
include: ['**/*.types.spec.ts'],
13+
},
1014
coverage: {
1115
provider: 'v8',
1216
include: ['lib/**/*.ts'],

0 commit comments

Comments
 (0)