diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 93bcd552f..522a46069 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -187,7 +187,7 @@ export class ContractBuilder< } export const oc = new ContractBuilder< - Schema, + Schema, Schema, Record, Record diff --git a/packages/contract/src/default-void-input.test-d.ts b/packages/contract/src/default-void-input.test-d.ts new file mode 100644 index 000000000..605b4fb55 --- /dev/null +++ b/packages/contract/src/default-void-input.test-d.ts @@ -0,0 +1,85 @@ +import type { Schema } from '@orpc/contract' +import { expectTypeOf } from 'vitest' +import { z } from 'zod' +import { oc } from './builder' + +describe('default void input - contract - type tests', () => { + it('should infer void input type when input() is not specified', () => { + const contract = oc + .output(z.string()) + + type InputSchema = typeof contract extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should match explicit void input type', () => { + const contractWithoutInput = oc + .output(z.string()) + + const contractWithVoidInput = oc + .input(z.void()) + .output(z.string()) + + type InputSchema1 = typeof contractWithoutInput extends { '~orpc': { inputSchema?: infer S } } ? S : never + type InputSchema2 = typeof contractWithVoidInput extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf() + }) + + it('should still allow explicit input schema to override', () => { + const contract = oc + .input(z.object({ name: z.string() })) + .output(z.string()) + + type InputSchema = typeof contract extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().not.toEqualTypeOf>() + }) + + it('should work with metadata chaining', () => { + const contract = oc + .meta({ description: 'A test procedure' }) + .output(z.string()) + + type InputSchema = typeof contract extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should work with route definition', () => { + const contract = oc + .route({ method: 'GET', path: '/test' }) + .output(z.string()) + + type InputSchema = typeof contract extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should work with error mapping', () => { + const contract = oc + .errors({ NOT_FOUND: z.object({ message: z.string() }) }) + .output(z.string()) + + type InputSchema = typeof contract extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should work in contract router definition', () => { + const contractRouter = { + withoutInput: oc + .output(z.string()), + withInput: oc + .input(z.object({ id: z.string() })) + .output(z.string()), + } + + type WithoutInputSchema = typeof contractRouter.withoutInput extends { '~orpc': { inputSchema?: infer S } } ? S : never + type WithInputSchema = typeof contractRouter.withInput extends { '~orpc': { inputSchema?: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().not.toEqualTypeOf>() + }) +}) diff --git a/packages/contract/src/default-void-input.test.ts b/packages/contract/src/default-void-input.test.ts new file mode 100644 index 000000000..83f928197 --- /dev/null +++ b/packages/contract/src/default-void-input.test.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { oc } from './builder' +import { isContractProcedure } from './procedure' + +describe('default void input - contract', () => { + it('should be a valid contract procedure', () => { + const contract = oc + .output(z.string()) + + expect(isContractProcedure(contract)).toBe(true) + }) + + it('should work with explicit void input same as default', () => { + const contractWithoutInput = oc + .output(z.string()) + + const contractWithVoidInput = oc + .input(z.void()) + .output(z.string()) + + expect(isContractProcedure(contractWithoutInput)).toBe(true) + expect(isContractProcedure(contractWithVoidInput)).toBe(true) + }) + + it('should still work with explicit input schema', () => { + const contract = oc + .input(z.object({ name: z.string() })) + .output(z.string()) + + const inputSchema = contract['~orpc'].inputSchema + + expect(inputSchema).toBeDefined() + expect(inputSchema?.['~standard'].vendor).toBe('zod') + }) + + it('should work in contract router without input()', () => { + const contractRouter = { + getAll: oc + .output(z.array(z.string())), + getOne: oc + .input(z.object({ id: z.string() })) + .output(z.string()), + } + + expect(isContractProcedure(contractRouter.getAll)).toBe(true) + expect(isContractProcedure(contractRouter.getOne)).toBe(true) + expect(contractRouter.getOne['~orpc'].inputSchema).toBeDefined() + }) + + it('should allow metadata chaining with default void input', () => { + const contract = oc + .meta({ description: 'A test procedure' }) + .output(z.string()) + + expect(isContractProcedure(contract)).toBe(true) + expect(contract['~orpc'].meta).toEqual({ description: 'A test procedure' }) + }) + + it('should allow route definition with default void input', () => { + const contract = oc + .route({ method: 'GET', path: '/test' }) + .output(z.string()) + + expect(isContractProcedure(contract)).toBe(true) + expect(contract['~orpc'].route).toEqual({ method: 'GET', path: '/test' }) + }) + + it('should allow error mapping with default void input', () => { + const contract = oc + .errors({ NOT_FOUND: z.object({ message: z.string() }) }) + .output(z.string()) + + expect(isContractProcedure(contract)).toBe(true) + expect(contract['~orpc'].errorMap).toHaveProperty('NOT_FOUND') + }) +}) diff --git a/packages/server/src/builder.ts b/packages/server/src/builder.ts index c1afe03fe..3fc56dea5 100644 --- a/packages/server/src/builder.ts +++ b/packages/server/src/builder.ts @@ -336,7 +336,7 @@ export class Builder< export const os = new Builder< Record, Record, - Schema, + Schema, Schema, Record, Record diff --git a/packages/server/src/default-void-input.test-d.ts b/packages/server/src/default-void-input.test-d.ts new file mode 100644 index 000000000..b92462084 --- /dev/null +++ b/packages/server/src/default-void-input.test-d.ts @@ -0,0 +1,86 @@ +import type { Schema } from '@orpc/contract' +import { expectTypeOf } from 'vitest' +import { z } from 'zod' +import { os } from './builder' + +describe('default void input - type tests', () => { + it('should infer void input type when input() is not specified', () => { + const procedure = os + .output(z.string()) + .handler(() => 'result') + + type InputSchema = typeof procedure extends { '~orpc': { inputSchema: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should allow handler without input parameter when input() is not specified', () => { + const procedure = os + .output(z.string()) + .handler(() => 'result') + + expectTypeOf(procedure).toMatchTypeOf<{ + '~orpc': { + inputSchema: Schema + outputSchema: Schema + } + }>() + }) + + it('should match explicit void input type', () => { + const procedureWithoutInput = os + .output(z.string()) + .handler(() => 'result1') + + const procedureWithVoidInput = os + .input(z.void()) + .output(z.string()) + .handler(() => 'result2') + + type InputSchema1 = typeof procedureWithoutInput extends { '~orpc': { inputSchema: infer S } } ? S : never + type InputSchema2 = typeof procedureWithVoidInput extends { '~orpc': { inputSchema: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf() + }) + + it('should still allow explicit input schema to override', () => { + const procedure = os + .input(z.object({ name: z.string() })) + .output(z.string()) + .handler(({ input }) => `Hello ${input.name}`) + + type InputSchema = typeof procedure extends { '~orpc': { inputSchema: infer S } } ? S : never + + expectTypeOf().not.toEqualTypeOf>() + }) + + it('should work with middleware chaining', () => { + const middleware = os.middleware(() => ({ context: { user: 'test' } })) + + const procedure = middleware + .output(z.string()) + .handler(({ context }) => `User: ${context.user}`) + + type InputSchema = typeof procedure extends { '~orpc': { inputSchema: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + }) + + it('should work in router definition', () => { + const testRouter = { + withoutInput: os + .output(z.string()) + .handler(() => 'result'), + withInput: os + .input(z.object({ id: z.string() })) + .output(z.string()) + .handler(({ input }) => `ID: ${input.id}`), + } + + type WithoutInputSchema = typeof testRouter.withoutInput extends { '~orpc': { inputSchema: infer S } } ? S : never + type WithInputSchema = typeof testRouter.withInput extends { '~orpc': { inputSchema: infer S } } ? S : never + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().not.toEqualTypeOf>() + }) +}) diff --git a/packages/server/src/default-void-input.test.ts b/packages/server/src/default-void-input.test.ts new file mode 100644 index 000000000..866214e9d --- /dev/null +++ b/packages/server/src/default-void-input.test.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { os } from './builder' + +describe('default void input', () => { + it('should accept no input when input() is not specified', async () => { + const procedure = os + .output(z.string()) + .handler(() => 'result') + + // Should work without providing input + const result = await procedure['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + expect(result).toBe('result') + }) + + it('should accept undefined as input when input() is not specified', async () => { + const procedure = os + .output(z.string()) + .handler(() => 'result') + + // Should work with undefined input + const result = await procedure['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + expect(result).toBe('result') + }) + + it('should work with explicit void input same as default', async () => { + const procedureWithoutInput = os + .output(z.string()) + .handler(() => 'result1') + + const procedureWithVoidInput = os + .input(z.void()) + .output(z.string()) + .handler(() => 'result2') + + const result1 = await procedureWithoutInput['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + const result2 = await procedureWithVoidInput['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + expect(result1).toBe('result1') + expect(result2).toBe('result2') + }) + + it('should still work with explicit input schema', async () => { + const procedure = os + .input(z.object({ name: z.string() })) + .output(z.string()) + .handler(({ input }) => `Hello ${input.name}`) + + const result = await procedure['~orpc'].handler({ + context: {}, + input: { name: 'World' }, + rawInput: { name: 'World' }, + }) + + expect(result).toBe('Hello World') + }) + + it('should work after use() without explicit input()', async () => { + const middleware = ({ next }) => next() + + const procedure = os + .use(middleware) + .output(z.string()) + .handler(() => 'result after middleware') + + const result = await procedure['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + expect(result).toBe('result after middleware') + }) + + it('should work in router without input()', async () => { + const testRouter = { + getAll: os + .output(z.array(z.string())) + .handler(() => ['item1', 'item2']), + getOne: os + .input(z.object({ id: z.string() })) + .output(z.string()) + .handler(({ input }) => `Item: ${input.id}`), + } + + const result1 = await testRouter.getAll['~orpc'].handler({ + context: {}, + input: undefined, + rawInput: undefined, + }) + + const result2 = await testRouter.getOne['~orpc'].handler({ + context: {}, + input: { id: '123' }, + rawInput: { id: '123' }, + }) + + expect(result1).toEqual(['item1', 'item2']) + expect(result2).toBe('Item: 123') + }) +})