Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to have optionals when chaining input parsers #3797

Merged
merged 9 commits into from
Feb 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
29 changes: 9 additions & 20 deletions packages/server/src/core/internals/procedureBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { TRPCError } from '../../error/TRPCError';
import { getTRPCErrorFromUnknown } from '../../error/utils';
import {
InferOptional,
MaybePromise,
Simplify,
UndefinedKeys,
} from '../../types';
import { MaybePromise, Simplify } from '../../types';
import {
MiddlewareBuilder,
MiddlewareFunction,
Expand Down Expand Up @@ -68,19 +63,9 @@ export interface BuildProcedure<
: TParams
> {}

type Merge<TType, TWith> = {
[TKey in keyof TType | keyof TWith]: TKey extends keyof TType
? TKey extends keyof TWith
? TType[TKey] & TWith[TKey]
: TType[TKey]
: TWith[TKey & keyof TWith];
};

type OverwriteIfDefined<TType, TWith> = UnsetMarker extends TType
? TWith
: Simplify<
InferOptional<Merge<TType, TWith>, UndefinedKeys<Merge<TType, TWith>>>
>;
: Simplify<TType & TWith>;

type ErrorMessage<TMessage extends string> = TMessage;

Expand All @@ -104,9 +89,13 @@ export interface ProcedureBuilder<TParams extends ProcedureParams> {
input<$Parser extends Parser>(
schema: TParams['_input_out'] extends UnsetMarker
? $Parser
: inferParser<$Parser>['out'] extends Record<string, unknown>
? TParams['_input_out'] extends Record<string, unknown>
? $Parser
: inferParser<$Parser>['out'] extends Record<string, unknown> | undefined
? TParams['_input_out'] extends Record<string, unknown> | undefined
? undefined extends inferParser<$Parser>['out'] // if current is optional the previous must be too
? undefined extends TParams['_input_out']
? $Parser
: ErrorMessage<'Cannot chain an optional parser to a required parser'>
: $Parser
: ErrorMessage<'All input parsers did not resolve to an object'>
: ErrorMessage<'All input parsers did not resolve to an object'>,
): ProcedureBuilder<{
Expand Down
156 changes: 156 additions & 0 deletions packages/tests/server/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,146 @@ test('only allow double input validator for object-like inputs', () => {
}
});

describe('multiple input validators with optionals', () => {
const t = initTRPC.create();

const webhookProc = t.procedure.input(
z
.object({
id: z.string(),
eventTypeId: z.number().optional(),
})
.optional(),
);

test('2nd parser also optional => merged optional', async () => {
const webhookRouter = t.router({
byId: webhookProc
.input(
z
.object({
webhookId: z.string(),
})
.optional(),
)
.query(({ input }) => {
expectTypeOf(input).toEqualTypeOf<
| {
id: string;
eventTypeId?: number;
webhookId: string;
}
| undefined
>();
return input;
}),
});

const opts = routerToServerAndClientNew(webhookRouter);

await expect(opts.proxy.byId.query()).resolves.toBeUndefined();
await expect(opts.proxy.byId.query(undefined)).resolves.toBeUndefined();
await expect(
opts.proxy.byId.query({ id: '123', webhookId: '456' }),
).resolves.toMatchObject({
id: '123',
webhookId: '456',
});

await opts.close();
});

test('2nd parser required => merged required', async () => {
const webhookRouter = t.router({
byId: webhookProc
.input(
z.object({
webhookId: z.string(),
}),
)
.query(({ input }) => {
expectTypeOf(input).toEqualTypeOf<{
id: string;
eventTypeId?: number;
webhookId: string;
}>();
return input;
}),
});

const opts = routerToServerAndClientNew(webhookRouter);

await expect(
opts.proxy.byId.query({ id: '123', webhookId: '456' }),
).resolves.toMatchObject({
id: '123',
webhookId: '456',
});
// @ts-expect-error - missing id and webhookId
await expect(opts.proxy.byId.query()).rejects.toThrow();
// @ts-expect-error - missing id and webhookId
await expect(opts.proxy.byId.query(undefined)).rejects.toThrow();
await expect(
opts.proxy.byId.query({ id: '123', eventTypeId: 1, webhookId: '456' }),
).resolves.toMatchObject({
id: '123',
eventTypeId: 1,
webhookId: '456',
});

await opts.close();
});

test('with optional keys', async () => {
const webhookRouter = t.router({
byId: webhookProc
.input(
z.object({
webhookId: z.string(),
foo: z.string().optional(),
}),
)
.query(({ input }) => {
expectTypeOf(input).toEqualTypeOf<{
id: string;
eventTypeId?: number;
webhookId: string;
foo?: string;
}>();
return input;
}),
});

const opts = routerToServerAndClientNew(webhookRouter);
await expect(
opts.proxy.byId.query({ id: '123', webhookId: '456' }),
).resolves.toMatchObject({
id: '123',
webhookId: '456',
});
await expect(
opts.proxy.byId.query({ id: '123', webhookId: '456', foo: 'bar' }),
).resolves.toMatchObject({
id: '123',
webhookId: '456',
foo: 'bar',
});

await opts.close();
});

test('cannot chain optional to required', async () => {
try {
t.procedure
.input(z.object({ foo: z.string() }))
// @ts-expect-error cannot chain optional to required
.input(z.object({ bar: z.number() }).optional());
} catch {
// whatever
}
});
});

test('no input', async () => {
const t = initTRPC.create();

Expand Down Expand Up @@ -358,6 +498,12 @@ test('double validators with undefined', async () => {
return input;
});

type Input = inferProcedureParams<typeof proc>['_input_in'];
expectTypeOf<Input>().toEqualTypeOf<{
roomId: string;
optionalKey?: string;
}>();

const router = t.router({
proc,
});
Expand Down Expand Up @@ -388,6 +534,12 @@ test('double validators with undefined', async () => {
return input;
});

type Input = inferProcedureParams<typeof proc>['_input_in'];
expectTypeOf<Input>().toEqualTypeOf<{
roomId?: string;
key: string;
}>();

const router = t.router({
proc,
});
Expand Down Expand Up @@ -421,6 +573,10 @@ test('merges optional with required property', async () => {
.query(() => 'hi'),
});

type Input = inferProcedureInput<typeof router['proc']>;
// ^?
expectTypeOf<Input>().toEqualTypeOf<{ id: string }>();

const client = createTRPCProxyClient<typeof router>({
links: [],
});
Expand Down