Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod/v4'
import type { GetRouteDefinition } from './apiContracts.ts'
import { buildContract } from './contractBuilder.ts'
import type { DualModeContractDefinition } from './sse/dualModeContracts.ts'
import type { SSEContractDefinition } from './sse/sseContracts.ts'

describe('buildContract type inference', () => {
// ============================================================================
Expand Down Expand Up @@ -397,7 +395,6 @@ describe('buildContract type inference', () => {
},
})

expectTypeOf(contract).toMatchTypeOf<SSEContractDefinition<'get'>>()
expectTypeOf(contract.method).toEqualTypeOf<'get'>()
expectTypeOf(contract.isSSE).toEqualTypeOf<true>()
})
Expand Down Expand Up @@ -641,7 +638,6 @@ describe('buildContract type inference', () => {
},
})

expectTypeOf(contract).toMatchTypeOf<DualModeContractDefinition<'get'>>()
expectTypeOf(contract.method).toEqualTypeOf<'get'>()
expectTypeOf(contract.isDualMode).toEqualTypeOf<true>()
})
Expand Down
8 changes: 4 additions & 4 deletions packages/app/api-contracts/src/sse/dualModeContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import type { SSEEventSchemas } from './sseTypes.ts'
*/
export type DualModeContractDefinition<
Method extends SSEMethod = SSEMethod,
Params extends z.ZodTypeAny = z.ZodTypeAny,
Query extends z.ZodTypeAny = z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny = z.ZodTypeAny,
Params extends z.ZodTypeAny | undefined = undefined,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was inconsistent with simple REST contracts for no good reason, which made using them harder

Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny | undefined = undefined,
SyncResponse extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
Expand All @@ -32,7 +32,7 @@ export type DualModeContractDefinition<
| undefined = undefined,
> = {
method: Method
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('contractBuilders', () => {
const route = buildSseContract(baseConfig)

expect(route.method).toBe('post')
expect(route.pathResolver({})).toBe('/api/test')
expect(route.pathResolver()).toBe('/api/test')
expect(route.isSSE).toBe(true)
})

Expand Down
88 changes: 44 additions & 44 deletions packages/app/api-contracts/src/sse/sseContractBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ import type { SSEEventSchemas } from './sseTypes.ts'
* Forbids requestBodySchema for GET variants.
*/
export type SSEGetContractConfig<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
> = {
method: 'get'
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down Expand Up @@ -51,17 +51,17 @@ export type SSEGetContractConfig<
* Requires requestBodySchema for payload variants.
*/
export type SSEPayloadContractConfig<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Body extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
> = {
method: 'post' | 'put' | 'patch'
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down Expand Up @@ -93,18 +93,18 @@ export type SSEPayloadContractConfig<
* Requires successResponseBodySchema, forbids requestBodySchema.
*/
export type DualModeGetContractConfig<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
JsonResponse extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseHeaders extends z.ZodTypeAny | undefined = undefined,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
> = {
method: 'get'
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down Expand Up @@ -149,19 +149,19 @@ export type DualModeGetContractConfig<
* Requires both requestBodySchema and successResponseBodySchema.
*/
export type DualModePayloadContractConfig<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Body extends z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny = z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseHeaders extends z.ZodTypeAny | undefined = undefined,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
> = {
method: 'post' | 'put' | 'patch'
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down Expand Up @@ -280,11 +280,11 @@ function determineMethod(config: { method: string }) {

// Overload 1: Dual-mode GET (has successResponseBodySchema, no requestBodySchema)
export function buildSseContract<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
JsonResponse extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseHeaders extends z.ZodTypeAny | undefined = undefined,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
Expand Down Expand Up @@ -313,10 +313,10 @@ export function buildSseContract<

// Overload 2: SSE GET (no requestBodySchema, no successResponseBodySchema)
export function buildSseContract<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
Expand All @@ -334,12 +334,12 @@ export function buildSseContract<

// Overload 3: Dual-mode with body (has successResponseBodySchema + requestBodySchema)
export function buildSseContract<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Body extends z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny = z.ZodTypeAny,
JsonResponse extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseHeaders extends z.ZodTypeAny | undefined = undefined,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
Expand Down Expand Up @@ -369,11 +369,11 @@ export function buildSseContract<

// Overload 4: SSE with body (has requestBodySchema, no successResponseBodySchema)
export function buildSseContract<
Params extends z.ZodTypeAny,
Query extends z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny,
Body extends z.ZodTypeAny,
Events extends SSEEventSchemas,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny = z.ZodTypeAny,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseSchemasByStatusCode extends
| Partial<Record<HttpStatusCode, z.ZodTypeAny>>
| undefined = undefined,
Expand Down
114 changes: 106 additions & 8 deletions packages/app/api-contracts/src/sse/sseContractBuilders.types.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod/v4'
import type { AnyDualModeContractDefinition } from './dualModeContracts.ts'
import type {
AnyDualModeContractDefinition,
DualModeContractDefinition,
} from './dualModeContracts.ts'
import { buildSseContract } from './sseContractBuilders.ts'
import type { AnySSEContractDefinition } from './sseContracts.ts'
import type { AnySSEContractDefinition, SSEContractDefinition } from './sseContracts.ts'

describe('buildSseContract type inference', () => {
// ============================================================================
Expand Down Expand Up @@ -567,15 +570,16 @@ describe('buildSseContract type inference', () => {
expectTypeOf<SafeHeaders>().toEqualTypeOf<{ authorization: string }>()
})

it('NonNullable + conditional z.infer resolves to unknown when schema is omitted', () => {
it('NonNullable + conditional z.infer resolves to never when schema is omitted', () => {
const contract = buildSseContract({
method: 'get' as const,
pathResolver: () => '/api/stream',
serverSentEventSchemas: { data: z.object({ value: z.string() }) },
})

// When omitted, NonNullable strips undefined, leaving ZodTypeAny.
// z.infer<ZodTypeAny> = unknown — which is the correct fallback.
// When omitted, the generic defaults to undefined.
// NonNullable<undefined> = never, and never extends z.ZodTypeAny (vacuously),
// so z.infer<never> = never — which is the correct fallback for omitted schemas.
type SafeParams =
NonNullable<(typeof contract)['requestPathParamsSchema']> extends z.ZodTypeAny
? z.infer<NonNullable<(typeof contract)['requestPathParamsSchema']>>
Expand All @@ -589,9 +593,9 @@ describe('buildSseContract type inference', () => {
? z.infer<NonNullable<(typeof contract)['requestHeaderSchema']>>
: unknown

expectTypeOf<SafeParams>().toBeUnknown()
expectTypeOf<SafeQuery>().toBeUnknown()
expectTypeOf<SafeHeaders>().toBeUnknown()
expectTypeOf<SafeParams>().toBeNever()
expectTypeOf<SafeQuery>().toBeNever()
expectTypeOf<SafeHeaders>().toBeNever()
})
})

Expand Down Expand Up @@ -691,4 +695,98 @@ describe('buildSseContract type inference', () => {
expectTypeOf(contract.isDualMode).toEqualTypeOf<true>()
})
})

// ============================================================================
// Generic defaults: Params/Query/Headers default to undefined (like REST)
// ============================================================================

describe('SSEContractDefinition generic defaults match REST pattern', () => {
it('SSEContractDefinition defaults Params/Query/Headers to undefined', () => {
type DefaultSSE = SSEContractDefinition<'get'>
expectTypeOf<DefaultSSE['requestPathParamsSchema']>().toEqualTypeOf<undefined>()
expectTypeOf<DefaultSSE['requestQuerySchema']>().toEqualTypeOf<undefined>()
expectTypeOf<DefaultSSE['requestHeaderSchema']>().toEqualTypeOf<undefined>()
})

it('contract without path params accepts undefined for requestPathParamsSchema', () => {
const contract = buildSseContract({
method: 'get' as const,
pathResolver: () => '/api/stream',
serverSentEventSchemas: { message: z.object({ text: z.string() }) },
})

expectTypeOf<undefined>().toMatchTypeOf<typeof contract.requestPathParamsSchema>()
})

it('contract with path params infers the schema type', () => {
const paramsSchema = z.object({ userId: z.string() })
const contract = buildSseContract({
method: 'get' as const,
pathResolver: (params) => `/users/${params.userId}/stream`,
requestPathParamsSchema: paramsSchema,
serverSentEventSchemas: { message: z.object({ text: z.string() }) },
})

expectTypeOf(contract.requestPathParamsSchema).toEqualTypeOf<
typeof paramsSchema | undefined
>()
})

it('contract without query params accepts undefined for requestQuerySchema', () => {
const contract = buildSseContract({
method: 'get' as const,
pathResolver: () => '/api/stream',
serverSentEventSchemas: { message: z.object({ text: z.string() }) },
})

expectTypeOf<undefined>().toMatchTypeOf<typeof contract.requestQuerySchema>()
})

it('contract with query params infers the schema type', () => {
const querySchema = z.object({ limit: z.number() })
const contract = buildSseContract({
method: 'get' as const,
pathResolver: () => '/api/stream',
requestQuerySchema: querySchema,
serverSentEventSchemas: { message: z.object({ text: z.string() }) },
})

expectTypeOf(contract.requestQuerySchema).toEqualTypeOf<typeof querySchema | undefined>()
})
})

describe('DualModeContractDefinition generic defaults match REST pattern', () => {
it('DualModeContractDefinition defaults Params/Query/Headers to undefined', () => {
type DefaultDual = DualModeContractDefinition<'get'>
expectTypeOf<DefaultDual['requestPathParamsSchema']>().toEqualTypeOf<undefined>()
expectTypeOf<DefaultDual['requestQuerySchema']>().toEqualTypeOf<undefined>()
expectTypeOf<DefaultDual['requestHeaderSchema']>().toEqualTypeOf<undefined>()
})

it('dual-mode contract without path params accepts undefined for requestPathParamsSchema', () => {
const contract = buildSseContract({
method: 'get' as const,
pathResolver: () => '/api/status',
successResponseBodySchema: z.object({ status: z.string() }),
serverSentEventSchemas: { update: z.object({ progress: z.number() }) },
})

expectTypeOf<undefined>().toMatchTypeOf<typeof contract.requestPathParamsSchema>()
})

it('dual-mode contract with path params infers the schema type', () => {
const paramsSchema = z.object({ id: z.string() })
const contract = buildSseContract({
method: 'get' as const,
pathResolver: (params) => `/api/items/${params.id}/status`,
requestPathParamsSchema: paramsSchema,
successResponseBodySchema: z.object({ status: z.string() }),
serverSentEventSchemas: { update: z.object({ progress: z.number() }) },
})

expectTypeOf(contract.requestPathParamsSchema).toEqualTypeOf<
typeof paramsSchema | undefined
>()
})
})
})
8 changes: 4 additions & 4 deletions packages/app/api-contracts/src/sse/sseContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export type SSEMethod = 'get' | 'post' | 'put' | 'patch'
*/
export type SSEContractDefinition<
Method extends SSEMethod = SSEMethod,
Params extends z.ZodTypeAny = z.ZodTypeAny,
Query extends z.ZodTypeAny = z.ZodTypeAny,
RequestHeaders extends z.ZodTypeAny = z.ZodTypeAny,
Params extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodTypeAny | undefined = undefined,
RequestHeaders extends z.ZodTypeAny | undefined = undefined,
Body extends z.ZodTypeAny | undefined = undefined,
Events extends SSEEventSchemas = SSEEventSchemas,
ResponseSchemasByStatusCode extends
Expand All @@ -37,7 +37,7 @@ export type SSEContractDefinition<
* Type-safe path resolver function.
* Receives typed params and returns the URL path string.
*/
pathResolver: RoutePathResolver<z.infer<Params>>
pathResolver: Params extends z.ZodTypeAny ? RoutePathResolver<z.infer<Params>> : () => string
requestPathParamsSchema?: Params
requestQuerySchema?: Query
requestHeaderSchema?: RequestHeaders
Expand Down
Loading
Loading