From 2b311aa36d5b09b417fee616eb3be2ac7b4c9c9b Mon Sep 17 00:00:00 2001 From: Henry Kwon Date: Fri, 24 Oct 2025 16:43:57 -0400 Subject: [PATCH 1/4] feat: contact & server default to void for input type Signed-off-by: Henry Kwon --- packages/contract/src/builder.ts | 2 +- packages/server/src/builder.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From 00b536e0443bc0153a18463baf9c37b83f924029 Mon Sep 17 00:00:00 2001 From: Henry Kwon Date: Fri, 24 Oct 2025 16:44:53 -0400 Subject: [PATCH 2/4] test: playground example to typecheck void inputs Signed-off-by: Henry Kwon --- playgrounds/next/src/app/orpc-mutation.tsx | 10 ++++++++++ playgrounds/next/src/routers/index.ts | 6 ++++++ playgrounds/next/src/routers/ping.ts | 5 +++++ playgrounds/next/src/schemas/ping.ts | 4 ++++ 4 files changed, 25 insertions(+) create mode 100644 playgrounds/next/src/routers/ping.ts create mode 100644 playgrounds/next/src/schemas/ping.ts diff --git a/playgrounds/next/src/app/orpc-mutation.tsx b/playgrounds/next/src/app/orpc-mutation.tsx index 2927148e6..39e0ca952 100644 --- a/playgrounds/next/src/app/orpc-mutation.tsx +++ b/playgrounds/next/src/app/orpc-mutation.tsx @@ -2,6 +2,7 @@ import { orpc } from '@/lib/orpc' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' export function CreatePlanetMutationForm() { const queryClient = useQueryClient() @@ -20,6 +21,15 @@ export function CreatePlanetMutationForm() { }), ) + const { mutate: testMutate } = useMutation(orpc.ping.run.mutationOptions()) + const { mutate: testVoidMutate } = useMutation(orpc.ping.runVoid.mutationOptions()) + + useCallback(() => { + testMutate() // this will throw an error if z.void() is not default of input schema + testMutate(undefined) // this will not throw an error + testVoidMutate() // this will not throw an error + }, [testMutate, testVoidMutate]) + return (

oRPC and Tanstack Query | Create Planet example

diff --git a/playgrounds/next/src/routers/index.ts b/playgrounds/next/src/routers/index.ts index 76109fce4..c49ec6342 100644 --- a/playgrounds/next/src/routers/index.ts +++ b/playgrounds/next/src/routers/index.ts @@ -1,4 +1,5 @@ import { me, signin, signup } from './auth' +import { ping, pingVoid } from './ping' import { createPlanet, findPlanet, listPlanets, updatePlanet } from './planet' import { sse } from './sse' @@ -17,4 +18,9 @@ export const router = { }, sse, + + ping: { + run: ping, + runVoid: pingVoid, + }, } diff --git a/playgrounds/next/src/routers/ping.ts b/playgrounds/next/src/routers/ping.ts new file mode 100644 index 000000000..d56273b7b --- /dev/null +++ b/playgrounds/next/src/routers/ping.ts @@ -0,0 +1,5 @@ +import { pub } from '@/orpc' +import { PingSchema, PingVoidSchema } from '@/schemas/ping' + +export const ping = pub.output(PingSchema).handler(() => 'pong') +export const pingVoid = pub.input(PingVoidSchema).handler(() => 'pong') diff --git a/playgrounds/next/src/schemas/ping.ts b/playgrounds/next/src/schemas/ping.ts new file mode 100644 index 000000000..f19db2281 --- /dev/null +++ b/playgrounds/next/src/schemas/ping.ts @@ -0,0 +1,4 @@ +import z from 'zod' + +export const PingSchema = z.string().describe('The word that comes after ping') +export const PingVoidSchema = z.void().describe('A void input') From 9ef0669ca45bc7df33f8213643b6a079ec6293d8 Mon Sep 17 00:00:00 2001 From: Henry Kwon Date: Fri, 24 Oct 2025 16:55:01 -0400 Subject: [PATCH 3/4] test: if default void input is well applied Signed-off-by: Henry Kwon --- .../contract/src/default-void-input.test-d.ts | 85 +++++++++++++ .../contract/src/default-void-input.test.ts | 76 +++++++++++ .../server/src/default-void-input.test-d.ts | 86 +++++++++++++ .../server/src/default-void-input.test.ts | 119 ++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 packages/contract/src/default-void-input.test-d.ts create mode 100644 packages/contract/src/default-void-input.test.ts create mode 100644 packages/server/src/default-void-input.test-d.ts create mode 100644 packages/server/src/default-void-input.test.ts 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/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') + }) +}) From 20bbde0dfa5ff5067e03198ee8493880bf79b77d Mon Sep 17 00:00:00 2001 From: Henry Kwon Date: Fri, 24 Oct 2025 17:18:12 -0400 Subject: [PATCH 4/4] revert: "test: playground example to typecheck void inputs" Signed-off-by: Henry Kwon --- playgrounds/next/src/app/orpc-mutation.tsx | 10 ---------- playgrounds/next/src/routers/index.ts | 6 ------ playgrounds/next/src/routers/ping.ts | 5 ----- playgrounds/next/src/schemas/ping.ts | 4 ---- 4 files changed, 25 deletions(-) delete mode 100644 playgrounds/next/src/routers/ping.ts delete mode 100644 playgrounds/next/src/schemas/ping.ts diff --git a/playgrounds/next/src/app/orpc-mutation.tsx b/playgrounds/next/src/app/orpc-mutation.tsx index 39e0ca952..2927148e6 100644 --- a/playgrounds/next/src/app/orpc-mutation.tsx +++ b/playgrounds/next/src/app/orpc-mutation.tsx @@ -2,7 +2,6 @@ import { orpc } from '@/lib/orpc' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' export function CreatePlanetMutationForm() { const queryClient = useQueryClient() @@ -21,15 +20,6 @@ export function CreatePlanetMutationForm() { }), ) - const { mutate: testMutate } = useMutation(orpc.ping.run.mutationOptions()) - const { mutate: testVoidMutate } = useMutation(orpc.ping.runVoid.mutationOptions()) - - useCallback(() => { - testMutate() // this will throw an error if z.void() is not default of input schema - testMutate(undefined) // this will not throw an error - testVoidMutate() // this will not throw an error - }, [testMutate, testVoidMutate]) - return (

oRPC and Tanstack Query | Create Planet example

diff --git a/playgrounds/next/src/routers/index.ts b/playgrounds/next/src/routers/index.ts index c49ec6342..76109fce4 100644 --- a/playgrounds/next/src/routers/index.ts +++ b/playgrounds/next/src/routers/index.ts @@ -1,5 +1,4 @@ import { me, signin, signup } from './auth' -import { ping, pingVoid } from './ping' import { createPlanet, findPlanet, listPlanets, updatePlanet } from './planet' import { sse } from './sse' @@ -18,9 +17,4 @@ export const router = { }, sse, - - ping: { - run: ping, - runVoid: pingVoid, - }, } diff --git a/playgrounds/next/src/routers/ping.ts b/playgrounds/next/src/routers/ping.ts deleted file mode 100644 index d56273b7b..000000000 --- a/playgrounds/next/src/routers/ping.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { pub } from '@/orpc' -import { PingSchema, PingVoidSchema } from '@/schemas/ping' - -export const ping = pub.output(PingSchema).handler(() => 'pong') -export const pingVoid = pub.input(PingVoidSchema).handler(() => 'pong') diff --git a/playgrounds/next/src/schemas/ping.ts b/playgrounds/next/src/schemas/ping.ts deleted file mode 100644 index f19db2281..000000000 --- a/playgrounds/next/src/schemas/ping.ts +++ /dev/null @@ -1,4 +0,0 @@ -import z from 'zod' - -export const PingSchema = z.string().describe('The word that comes after ping') -export const PingVoidSchema = z.void().describe('A void input')