Skip to content

Commit 81cd392

Browse files
authoredAug 29, 2024··
feat: support output data validation (#250)
This PR adds the `outputSchema` method to allow for optional validation of the action's return value. re #245
1 parent 84e15e3 commit 81cd392

16 files changed

+281
-139
lines changed
 

‎README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/664eb3ee-92f3-4d4a-
1717
- ✅ End-to-end type safety
1818
- ✅ Form Actions support
1919
- ✅ Powerful middleware system
20-
- ✅ Input validation using multiple validation libraries
20+
- ✅ Input/output validation using multiple validation libraries
2121
- ✅ Advanced server error handling
2222
- ✅ Optimistic updates
2323

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"cz-conventional-changelog": "^3.3.0",
3737
"husky": "^9.0.11",
3838
"is-ci": "^3.0.1",
39-
"turbo": "^2.0.14"
39+
"turbo": "^2.1.0"
4040
},
4141
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
4242
}

‎packages/next-safe-action/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/664eb3ee-92f3-4d4a-
1717
- ✅ End-to-end type safety
1818
- ✅ Form Actions support
1919
- ✅ Powerful middleware system
20-
- ✅ Input validation using multiple validation libraries
20+
- ✅ Input/output validation using multiple validation libraries
2121
- ✅ Advanced server error handling
2222
- ✅ Optimistic updates
2323

‎packages/next-safe-action/src/__tests__/happy-path.test.ts

+15-9
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,17 @@ test("action with no input schema and return data gives back an object with corr
3838
assert.deepStrictEqual(actualResult, expectedResult);
3939
});
4040

41-
test("action with input schema and return data gives back an object with correct `data`", async () => {
41+
test("action with input, output schema and return data gives back an object with correct `data`", async () => {
4242
const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996";
4343

44-
const action = ac.schema(z.object({ userId: z.string().uuid() })).action(async ({ parsedInput }) => {
45-
return {
46-
userId: parsedInput.userId,
47-
};
48-
});
44+
const action = ac
45+
.schema(z.object({ userId: z.string().uuid() }))
46+
.outputSchema(z.object({ userId: z.string() }))
47+
.action(async ({ parsedInput }) => {
48+
return {
49+
userId: parsedInput.userId,
50+
};
51+
});
4952

5053
const actualResult = await action({ userId });
5154

@@ -80,13 +83,14 @@ test("action with input schema passed via async function and return data gives b
8083
assert.deepStrictEqual(actualResult, expectedResult);
8184
});
8285

83-
test("action with input schema extended via async function and return data gives back an object with correct `data`", async () => {
86+
test("action with input schema extended via async function, ouput schema and return data gives back an object with correct `data`", async () => {
8487
const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996";
8588
const password = "password";
8689

8790
const action = ac
8891
.schema(z.object({ password: z.string() }))
8992
.schema(async (prevSchema) => prevSchema.extend({ userId: z.string().uuid() }))
93+
.outputSchema(z.object({ userId: z.string(), password: z.string() }))
9094
.action(async ({ parsedInput }) => {
9195
return {
9296
userId: parsedInput.userId,
@@ -106,12 +110,13 @@ test("action with input schema extended via async function and return data gives
106110
assert.deepStrictEqual(actualResult, expectedResult);
107111
});
108112

109-
test("action with no input schema, bind args input schemas and return data gives back an object with correct `data`", async () => {
113+
test("action with no input schema, with bind args input schemas, output schema and return data gives back an object with correct `data`", async () => {
110114
const username = "johndoe";
111115
const age = 30;
112116

113117
const action = ac
114118
.bindArgsSchemas<[username: z.ZodString, age: z.ZodNumber]>([z.string(), z.number()])
119+
.outputSchema(z.object({ username: z.string(), age: z.number() }))
115120
.action(async ({ bindArgsParsedInputs: [username, age] }) => {
116121
return {
117122
username,
@@ -131,14 +136,15 @@ test("action with no input schema, bind args input schemas and return data gives
131136
assert.deepStrictEqual(actualResult, expectedResult);
132137
});
133138

134-
test("action with input schema, bind args input schemas and return data gives back an object with correct `data`", async () => {
139+
test("action with input schema, bind args input schemas, output schema and return data gives back an object with correct `data`", async () => {
135140
const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996";
136141
const username = "johndoe";
137142
const age = 30;
138143

139144
const action = ac
140145
.schema(z.object({ userId: z.string().uuid() }))
141146
.bindArgsSchemas<[username: z.ZodString, age: z.ZodNumber]>([z.string(), z.number()])
147+
.outputSchema(z.object({ userId: z.string(), username: z.string(), age: z.number() }))
142148
.action(async ({ parsedInput, bindArgsParsedInputs: [username, age] }) => {
143149
return {
144150
userId: parsedInput.userId,

‎packages/next-safe-action/src/__tests__/validation-errors.test.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
import assert from "node:assert";
44
import { test } from "node:test";
55
import { z } from "zod";
6-
import { createSafeActionClient, flattenValidationErrors, formatValidationErrors, returnValidationErrors } from "..";
6+
import type { ValidationErrors } from "..";
7+
import {
8+
createSafeActionClient,
9+
DEFAULT_SERVER_ERROR_MESSAGE,
10+
flattenValidationErrors,
11+
formatValidationErrors,
12+
returnValidationErrors,
13+
} from "..";
714
import { zodAdapter } from "../adapters/zod";
15+
import { ActionOutputDataValidationError } from "../validation-errors";
816

917
// Default client tests.
1018

@@ -144,6 +152,58 @@ test("action with invalid input gives back an object with correct `validationErr
144152
assert.deepStrictEqual(actualResult, expectedResult);
145153
});
146154

155+
test("action with invalid output data returns the default `serverError`", async () => {
156+
const action = dac.outputSchema(z.object({ result: z.string().min(3) })).action(async () => {
157+
return {
158+
result: "ok",
159+
};
160+
});
161+
162+
const actualResult = await action();
163+
164+
const expectedResult = {
165+
serverError: DEFAULT_SERVER_ERROR_MESSAGE,
166+
};
167+
168+
assert.deepStrictEqual(actualResult, expectedResult);
169+
});
170+
171+
test("action with invalid output data throws an error of the correct type", async () => {
172+
const tac = createSafeActionClient({
173+
validationAdapter: zodAdapter(),
174+
handleReturnedServerError: (e) => {
175+
throw e;
176+
},
177+
});
178+
179+
const outputSchema = z.object({ result: z.string().min(3) });
180+
181+
const action = tac.outputSchema(outputSchema).action(async () => {
182+
return {
183+
result: "ok",
184+
};
185+
});
186+
187+
const expectedResult = {
188+
serverError: "String must contain at least 3 character(s)",
189+
};
190+
191+
const actualResult = {
192+
serverError: "",
193+
};
194+
195+
try {
196+
await action();
197+
} catch (e) {
198+
if (e instanceof ActionOutputDataValidationError) {
199+
actualResult.serverError =
200+
(e.validationErrors as ValidationErrors<typeof outputSchema>).result?._errors?.[0] ?? "";
201+
}
202+
}
203+
204+
assert.deepStrictEqual(actualResult, expectedResult);
205+
});
206+
147207
// Formatted shape tests (same as default).
148208

149209
const foac = createSafeActionClient({

‎packages/next-safe-action/src/action-builder.ts

+55-39
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ import type {
1414
ServerCodeFn,
1515
StateServerCodeFn,
1616
} from "./index.types";
17-
import { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils";
17+
import { DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils";
1818
import type { MaybePromise } from "./utils.types";
19-
import { ActionServerValidationError, ActionValidationError, buildValidationErrors } from "./validation-errors";
19+
import {
20+
ActionMetadataValidationError,
21+
ActionOutputDataValidationError,
22+
ActionServerValidationError,
23+
ActionValidationError,
24+
buildValidationErrors,
25+
} from "./validation-errors";
2026
import type {
2127
BindArgsValidationErrors,
2228
HandleBindArgsValidationErrorsShapeFn,
@@ -27,18 +33,20 @@ import type {
2733
export function actionBuilder<
2834
ServerError,
2935
MetadataSchema extends Schema | undefined = undefined,
30-
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
36+
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined, // metadata type (inferred from metadata schema)
3137
Ctx extends object = {},
32-
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
33-
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
38+
ISF extends (() => Promise<Schema>) | undefined = undefined, // input schema function
39+
IS extends Schema | undefined = ISF extends Function ? Awaited<ReturnType<ISF>> : undefined, // input schema
40+
OS extends Schema | undefined = undefined, // output schema
3441
const BAS extends readonly Schema[] = [],
3542
CVE = undefined,
3643
CBAVE = undefined,
3744
>(args: {
38-
schemaFn?: SF;
45+
inputSchemaFn?: ISF;
3946
bindArgsSchemas?: BAS;
47+
outputSchema?: OS;
4048
validationAdapter: ValidationAdapter;
41-
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
49+
handleValidationErrorsShape: HandleValidationErrorsShapeFn<IS, CVE>;
4250
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
4351
metadataSchema: MetadataSchema;
4452
metadata: MD;
@@ -53,29 +61,29 @@ export function actionBuilder<
5361
const bindArgsSchemas = (args.bindArgsSchemas ?? []) as BAS;
5462

5563
function buildAction({ withState }: { withState: false }): {
56-
action: <Data>(
57-
serverCodeFn: ServerCodeFn<MD, Ctx, S, BAS, Data>,
58-
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
59-
) => SafeActionFn<ServerError, S, BAS, CVE, CBAVE, Data>;
64+
action: <Data extends OS extends Schema ? Infer<OS> : any>(
65+
serverCodeFn: ServerCodeFn<MD, Ctx, IS, BAS, Data>,
66+
utils?: SafeActionUtils<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>
67+
) => SafeActionFn<ServerError, IS, BAS, CVE, CBAVE, Data>;
6068
};
6169
function buildAction({ withState }: { withState: true }): {
62-
action: <Data>(
63-
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
64-
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
65-
) => SafeStateActionFn<ServerError, S, BAS, CVE, CBAVE, Data>;
70+
action: <Data extends OS extends Schema ? Infer<OS> : any>(
71+
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>,
72+
utils?: SafeActionUtils<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>
73+
) => SafeStateActionFn<ServerError, IS, BAS, CVE, CBAVE, Data>;
6674
};
6775
function buildAction({ withState }: { withState: boolean }) {
6876
return {
69-
action: <Data>(
77+
action: <Data extends OS extends Schema ? Infer<OS> : any>(
7078
serverCodeFn:
71-
| ServerCodeFn<MD, Ctx, S, BAS, Data>
72-
| StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
73-
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
79+
| ServerCodeFn<MD, Ctx, IS, BAS, Data>
80+
| StateServerCodeFn<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>,
81+
utils?: SafeActionUtils<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>
7482
) => {
7583
return async (...clientInputs: unknown[]) => {
7684
let currentCtx: object = {};
7785
const middlewareResult: MiddlewareResult<ServerError, object> = { success: false };
78-
type PrevResult = SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data> | undefined;
86+
type PrevResult = SafeActionResult<ServerError, IS, BAS, CVE, CBAVE, Data> | undefined;
7987
let prevResult: PrevResult | undefined = undefined;
8088
const parsedInputDatas: any[] = [];
8189
let frameworkError: Error | null = null;
@@ -107,10 +115,10 @@ export function actionBuilder<
107115
if (idx === 0) {
108116
if (args.metadataSchema) {
109117
// Validate metadata input.
110-
if (!(await args.validationAdapter.validate(args.metadataSchema, args.metadata)).success) {
111-
throw new ActionMetadataError(
112-
"Invalid metadata input. Please be sure to pass metadata via `metadata` method before defining the action."
113-
);
118+
const parsedMd = await args.validationAdapter.validate(args.metadataSchema, args.metadata);
119+
120+
if (!parsedMd.success) {
121+
throw new ActionMetadataValidationError<MetadataSchema>(buildValidationErrors(parsedMd.issues));
114122
}
115123
}
116124
}
@@ -124,7 +132,6 @@ export function actionBuilder<
124132
metadata: args.metadata,
125133
next: async (nextOpts) => {
126134
currentCtx = deepmerge(currentCtx, nextOpts?.ctx ?? {});
127-
// currentCtx = { ...cloneDeep(currentCtx), ...(nextOpts?.ctx ?? {}) };
128135
await executeMiddlewareStack(idx + 1);
129136
return middlewareResult;
130137
},
@@ -137,15 +144,15 @@ export function actionBuilder<
137144
// Last client input in the array, main argument (no bind arg).
138145
if (i === clientInputs.length - 1) {
139146
// If schema is undefined, set parsed data to undefined.
140-
if (typeof args.schemaFn === "undefined") {
147+
if (typeof args.inputSchemaFn === "undefined") {
141148
return {
142149
success: true,
143150
data: undefined,
144151
} as const;
145152
}
146153

147154
// Otherwise, parse input with the schema.
148-
return args.validationAdapter.validate(await args.schemaFn(), input);
155+
return args.validationAdapter.validate(await args.inputSchemaFn(), input);
149156
}
150157

151158
// Otherwise, we're processing bind args client inputs.
@@ -172,7 +179,7 @@ export function actionBuilder<
172179
hasBindValidationErrors = true;
173180
} else {
174181
// Otherwise, we're processing the non-bind argument (the last one) in the array.
175-
const validationErrors = buildValidationErrors<S>(parsedInput.issues);
182+
const validationErrors = buildValidationErrors<IS>(parsedInput.issues);
176183

177184
middlewareResult.validationErrors = await Promise.resolve(
178185
args.handleValidationErrorsShape(validationErrors)
@@ -193,11 +200,11 @@ export function actionBuilder<
193200
}
194201

195202
// @ts-expect-error
196-
const scfArgs: Parameters<StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>> = [];
203+
const scfArgs: Parameters<StateServerCodeFn<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>> = [];
197204

198205
// Server code function always has this object as the first argument.
199206
scfArgs[0] = {
200-
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
207+
parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer<IS> : undefined,
201208
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
202209
ctx: currentCtx as Ctx,
203210
metadata: args.metadata,
@@ -211,6 +218,15 @@ export function actionBuilder<
211218

212219
const data = await serverCodeFn(...scfArgs);
213220

221+
// If a `outputSchema` is passed, validate the action return value.
222+
if (typeof args.outputSchema !== "undefined") {
223+
const parsedData = await args.validationAdapter.validate(args.outputSchema, data);
224+
225+
if (!parsedData.success) {
226+
throw new ActionOutputDataValidationError<OS>(buildValidationErrors(parsedData.issues));
227+
}
228+
}
229+
214230
middlewareResult.success = true;
215231
middlewareResult.data = data;
216232
middlewareResult.parsedInput = parsedInputDatas.at(-1);
@@ -227,7 +243,7 @@ export function actionBuilder<
227243

228244
// If error is `ActionServerValidationError`, return `validationErrors` as if schema validation would fail.
229245
if (e instanceof ActionServerValidationError) {
230-
const ve = e.validationErrors as ValidationErrors<S>;
246+
const ve = e.validationErrors as ValidationErrors<IS>;
231247
middlewareResult.validationErrors = await Promise.resolve(args.handleValidationErrorsShape(ve));
232248
} else {
233249
// If error is not an instance of Error, wrap it in an Error object with
@@ -269,9 +285,9 @@ export function actionBuilder<
269285
data: undefined,
270286
metadata: args.metadata,
271287
ctx: currentCtx as Ctx,
272-
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
288+
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
273289
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
274-
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
290+
parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer<IS> : undefined,
275291
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
276292
hasRedirected: isRedirectError(frameworkError),
277293
hasNotFound: isNotFoundError(frameworkError),
@@ -282,7 +298,7 @@ export function actionBuilder<
282298
utils?.onSettled?.({
283299
metadata: args.metadata,
284300
ctx: currentCtx as Ctx,
285-
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
301+
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
286302
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
287303
result: {},
288304
hasRedirected: isRedirectError(frameworkError),
@@ -295,7 +311,7 @@ export function actionBuilder<
295311
throw frameworkError;
296312
}
297313

298-
const actionResult: SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data> = {};
314+
const actionResult: SafeActionResult<ServerError, IS, BAS, CVE, CBAVE, Data> = {};
299315

300316
if (typeof middlewareResult.validationErrors !== "undefined") {
301317
// Throw validation errors if either `throwValidationErrors` property at the action or instance level is `true`.
@@ -333,9 +349,9 @@ export function actionBuilder<
333349
metadata: args.metadata,
334350
ctx: currentCtx as Ctx,
335351
data: actionResult.data as Data,
336-
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
352+
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
337353
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
338-
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
354+
parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer<IS> : undefined,
339355
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
340356
hasRedirected: false,
341357
hasNotFound: false,
@@ -346,7 +362,7 @@ export function actionBuilder<
346362
utils?.onError?.({
347363
metadata: args.metadata,
348364
ctx: currentCtx as Ctx,
349-
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
365+
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
350366
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
351367
error: actionResult,
352368
})
@@ -358,7 +374,7 @@ export function actionBuilder<
358374
utils?.onSettled?.({
359375
metadata: args.metadata,
360376
ctx: currentCtx as Ctx,
361-
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
377+
clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn<IS> : undefined,
362378
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
363379
result: actionResult,
364380
hasRedirected: false,

‎packages/next-safe-action/src/index.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
} from "./validation-errors";
1212

1313
export { createMiddleware } from "./middleware";
14-
export { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE } from "./utils";
14+
export { DEFAULT_SERVER_ERROR_MESSAGE } from "./utils";
1515
export {
16+
ActionMetadataValidationError,
17+
ActionOutputDataValidationError,
1618
ActionValidationError,
1719
flattenBindArgsValidationErrors,
1820
flattenValidationErrors,
@@ -59,8 +61,9 @@ export const createSafeActionClient = <
5961
middlewareFns: [async ({ next }) => next({ ctx: {} })],
6062
handleServerErrorLog,
6163
handleReturnedServerError,
62-
schemaFn: undefined,
64+
inputSchemaFn: undefined,
6365
bindArgsSchemas: [],
66+
outputSchema: undefined,
6467
validationAdapter: createOpts?.validationAdapter ?? zodAdapter(), // use zod adapter by default
6568
ctxType: {},
6669
metadataSchema: (createOpts?.defineMetadataSchema?.() ?? undefined) as MetadataSchema,

‎packages/next-safe-action/src/safe-action-client.ts

+67-32
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ export class SafeActionClient<
2222
ServerError,
2323
ODVES extends DVES | undefined, // override default validation errors shape
2424
MetadataSchema extends Schema | undefined = undefined,
25-
MD = MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
25+
MD = MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined, // metadata type (inferred from metadata schema)
2626
Ctx extends object = {},
27-
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
28-
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
27+
ISF extends (() => Promise<Schema>) | undefined = undefined, // input schema function
28+
IS extends Schema | undefined = ISF extends Function ? Awaited<ReturnType<ISF>> : undefined, // input schema
29+
OS extends Schema | undefined = undefined, // output schema
2930
const BAS extends readonly Schema[] = [],
3031
CVE = undefined,
3132
const CBAVE = undefined,
@@ -39,11 +40,12 @@ export class SafeActionClient<
3940
readonly #middlewareFns: MiddlewareFn<ServerError, any, any, any>[];
4041
readonly #metadataSchema: MetadataSchema;
4142
readonly #metadata: MD;
42-
readonly #schemaFn: SF;
43+
readonly #inputSchemaFn: ISF;
44+
readonly #outputSchema: OS;
4345
readonly #ctxType: Ctx;
4446
readonly #bindArgsSchemas: BAS;
4547
readonly #validationAdapter: ValidationAdapter;
46-
readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
48+
readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn<IS, CVE>;
4749
readonly #handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
4850
readonly #defaultValidationErrorsShape: ODVES;
4951
readonly #throwValidationErrors: boolean;
@@ -53,10 +55,11 @@ export class SafeActionClient<
5355
middlewareFns: MiddlewareFn<ServerError, any, any, any>[];
5456
metadataSchema: MetadataSchema;
5557
metadata: MD;
56-
schemaFn: SF;
58+
inputSchemaFn: ISF;
59+
outputSchema: OS;
5760
bindArgsSchemas: BAS;
5861
validationAdapter: ValidationAdapter;
59-
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
62+
handleValidationErrorsShape: HandleValidationErrorsShapeFn<IS, CVE>;
6063
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
6164
ctxType: Ctx;
6265
} & Required<
@@ -71,7 +74,8 @@ export class SafeActionClient<
7174
this.#handleReturnedServerError = opts.handleReturnedServerError;
7275
this.#metadataSchema = opts.metadataSchema;
7376
this.#metadata = opts.metadata;
74-
this.#schemaFn = (opts.schemaFn ?? undefined) as SF;
77+
this.#inputSchemaFn = (opts.inputSchemaFn ?? undefined) as ISF;
78+
this.#outputSchema = opts.outputSchema;
7579
this.#bindArgsSchemas = opts.bindArgsSchemas ?? [];
7680
this.#validationAdapter = opts.validationAdapter;
7781
this.#ctxType = opts.ctxType as unknown as Ctx;
@@ -94,7 +98,8 @@ export class SafeActionClient<
9498
handleServerErrorLog: this.#handleServerErrorLog,
9599
metadataSchema: this.#metadataSchema,
96100
metadata: this.#metadata,
97-
schemaFn: this.#schemaFn,
101+
inputSchemaFn: this.#inputSchemaFn,
102+
outputSchema: this.#outputSchema,
98103
bindArgsSchemas: this.#bindArgsSchemas,
99104
validationAdapter: this.#validationAdapter,
100105
handleValidationErrorsShape: this.#handleValidationErrorsShape,
@@ -118,8 +123,9 @@ export class SafeActionClient<
118123
handleServerErrorLog: this.#handleServerErrorLog,
119124
metadataSchema: this.#metadataSchema,
120125
metadata: data,
121-
schemaFn: this.#schemaFn,
126+
inputSchemaFn: this.#inputSchemaFn,
122127
bindArgsSchemas: this.#bindArgsSchemas,
128+
outputSchema: this.#outputSchema,
123129
validationAdapter: this.#validationAdapter,
124130
handleValidationErrorsShape: this.#handleValidationErrorsShape,
125131
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
@@ -131,19 +137,19 @@ export class SafeActionClient<
131137

132138
/**
133139
* Define the input validation schema for the action.
134-
* @param schema Input validation schema
140+
* @param inputSchema Input validation schema
135141
* @param utils Optional utils object
136142
*
137-
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information}
143+
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#inputschema See docs for more information}
138144
*/
139145
schema<
140-
OS extends Schema | ((prevSchema: S) => Promise<Schema>),
141-
AS extends Schema = OS extends (prevSchema: S) => Promise<Schema> ? Awaited<ReturnType<OS>> : OS, // actual schema
142-
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<AS>> : ValidationErrors<AS>,
146+
OIS extends Schema | ((prevSchema: IS) => Promise<Schema>), // override input schema
147+
AIS extends Schema = OIS extends (prevSchema: IS) => Promise<Schema> ? Awaited<ReturnType<OIS>> : OIS, // actual input schema
148+
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<AIS>> : ValidationErrors<AIS>,
143149
>(
144-
schema: OS,
150+
inputSchema: OIS,
145151
utils?: {
146-
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<AS, OCVE>;
152+
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<AIS, OCVE>;
147153
}
148154
) {
149155
return new SafeActionClient({
@@ -153,17 +159,18 @@ export class SafeActionClient<
153159
metadataSchema: this.#metadataSchema,
154160
metadata: this.#metadata,
155161
// @ts-expect-error
156-
schemaFn: (schema[Symbol.toStringTag] === "AsyncFunction"
162+
inputSchemaFn: (inputSchema[Symbol.toStringTag] === "AsyncFunction"
157163
? async () => {
158-
const prevSchema = await this.#schemaFn?.();
164+
const prevSchema = await this.#inputSchemaFn?.();
159165
// @ts-expect-error
160-
return schema(prevSchema as S) as AS;
166+
return inputSchema(prevSchema as IS) as AIS;
161167
}
162-
: async () => schema) as SF,
168+
: async () => inputSchema) as ISF,
163169
bindArgsSchemas: this.#bindArgsSchemas,
170+
outputSchema: this.#outputSchema,
164171
validationAdapter: this.#validationAdapter,
165172
handleValidationErrorsShape: (utils?.handleValidationErrorsShape ??
166-
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<AS, OCVE>,
173+
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<AIS, OCVE>,
167174
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
168175
ctxType: {} as Ctx,
169176
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
@@ -176,7 +183,7 @@ export class SafeActionClient<
176183
* @param bindArgsSchemas Bind args input validation schemas
177184
* @param utils Optional utils object
178185
*
179-
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information}
186+
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#bindargsschemas See docs for more information}
180187
*/
181188
bindArgsSchemas<
182189
const OBAS extends readonly Schema[],
@@ -193,8 +200,9 @@ export class SafeActionClient<
193200
handleServerErrorLog: this.#handleServerErrorLog,
194201
metadataSchema: this.#metadataSchema,
195202
metadata: this.#metadata,
196-
schemaFn: this.#schemaFn,
203+
inputSchemaFn: this.#inputSchemaFn,
197204
bindArgsSchemas,
205+
outputSchema: this.#outputSchema,
198206
validationAdapter: this.#validationAdapter,
199207
handleValidationErrorsShape: this.#handleValidationErrorsShape,
200208
handleBindArgsValidationErrorsShape: (utils?.handleBindArgsValidationErrorsShape ??
@@ -205,16 +213,41 @@ export class SafeActionClient<
205213
});
206214
}
207215

216+
/**
217+
* Define the output data validation schema for the action.
218+
* @param schema Output data validation schema
219+
*
220+
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#outputschema See docs for more information}
221+
*/
222+
outputSchema<OOS extends Schema>(dataSchema: OOS) {
223+
return new SafeActionClient({
224+
middlewareFns: this.#middlewareFns,
225+
handleReturnedServerError: this.#handleReturnedServerError,
226+
handleServerErrorLog: this.#handleServerErrorLog,
227+
metadataSchema: this.#metadataSchema,
228+
metadata: this.#metadata,
229+
inputSchemaFn: this.#inputSchemaFn,
230+
bindArgsSchemas: this.#bindArgsSchemas,
231+
outputSchema: dataSchema,
232+
validationAdapter: this.#validationAdapter,
233+
handleValidationErrorsShape: this.#handleValidationErrorsShape,
234+
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
235+
ctxType: {} as Ctx,
236+
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
237+
throwValidationErrors: this.#throwValidationErrors,
238+
});
239+
}
240+
208241
/**
209242
* Define the action.
210243
* @param serverCodeFn Code that will be executed on the **server side**
211244
* @param [cb] Optional callbacks that will be called after action execution, on the server.
212245
*
213246
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#action--stateaction See docs for more information}
214247
*/
215-
action<Data>(
216-
serverCodeFn: ServerCodeFn<MD, Ctx, S, BAS, Data>,
217-
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
248+
action<Data extends OS extends Schema ? Infer<OS> : any>(
249+
serverCodeFn: ServerCodeFn<MD, Ctx, IS, BAS, Data>,
250+
utils?: SafeActionUtils<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>
218251
) {
219252
return actionBuilder({
220253
handleReturnedServerError: this.#handleReturnedServerError,
@@ -223,8 +256,9 @@ export class SafeActionClient<
223256
ctxType: this.#ctxType,
224257
metadataSchema: this.#metadataSchema,
225258
metadata: this.#metadata,
226-
schemaFn: this.#schemaFn,
259+
inputSchemaFn: this.#inputSchemaFn,
227260
bindArgsSchemas: this.#bindArgsSchemas,
261+
outputSchema: this.#outputSchema,
228262
validationAdapter: this.#validationAdapter,
229263
handleValidationErrorsShape: this.#handleValidationErrorsShape,
230264
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
@@ -240,9 +274,9 @@ export class SafeActionClient<
240274
*
241275
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#action--stateaction See docs for more information}
242276
*/
243-
stateAction<Data>(
244-
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>,
245-
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
277+
stateAction<Data extends OS extends Schema ? Infer<OS> : any>(
278+
serverCodeFn: StateServerCodeFn<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>,
279+
utils?: SafeActionUtils<ServerError, MD, Ctx, IS, BAS, CVE, CBAVE, Data>
246280
) {
247281
return actionBuilder({
248282
handleReturnedServerError: this.#handleReturnedServerError,
@@ -251,8 +285,9 @@ export class SafeActionClient<
251285
ctxType: this.#ctxType,
252286
metadataSchema: this.#metadataSchema,
253287
metadata: this.#metadata,
254-
schemaFn: this.#schemaFn,
288+
inputSchemaFn: this.#inputSchemaFn,
255289
bindArgsSchemas: this.#bindArgsSchemas,
290+
outputSchema: this.#outputSchema,
256291
validationAdapter: this.#validationAdapter,
257292
handleValidationErrorsShape: this.#handleValidationErrorsShape,
258293
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
-11
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
11
export const DEFAULT_SERVER_ERROR_MESSAGE = "Something went wrong while executing the operation.";
22

33
export const isError = (error: unknown): error is Error => error instanceof Error;
4-
5-
/**
6-
* This error is thrown when an action's metadata input is invalid, i.e. when there's a mismatch between the
7-
* type of the metadata schema returned from `defineMetadataSchema` and the actual input.
8-
*/
9-
export class ActionMetadataError extends Error {
10-
constructor(message: string) {
11-
super(message);
12-
this.name = "ActionMetadataError";
13-
}
14-
}

‎packages/next-safe-action/src/validation-errors.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
22

3-
import type { Schema } from "./adapters/types";
3+
import type { Schema, ValidationIssue } from "./adapters/types";
44
import type {
55
FlattenedBindArgsValidationErrors,
66
FlattenedValidationErrors,
77
ValidationErrors,
8-
ValidationIssue,
98
} from "./validation-errors.types";
109

1110
// This function is used internally to build the validation errors object from a list of validation issues.
@@ -145,3 +144,33 @@ export function flattenBindArgsValidationErrors<BAVE extends readonly Validation
145144
) {
146145
return bindArgsValidationErrors.map((ve) => flattenValidationErrors(ve)) as FlattenedBindArgsValidationErrors<BAVE>;
147146
}
147+
148+
/**
149+
* This error is thrown when an action metadata is invalid, i.e. when there's a mismatch between the
150+
* type of the metadata schema returned from `defineMetadataSchema` and the actual data passed.
151+
*/
152+
export class ActionMetadataValidationError<MDS extends Schema | undefined> extends Error {
153+
public validationErrors: ValidationErrors<MDS>;
154+
155+
constructor(validationErrors: ValidationErrors<MDS>) {
156+
super("Invalid metadata input. Please be sure to pass metadata via `metadata` method before defining the action.");
157+
this.name = "ActionMetadataError";
158+
this.validationErrors = validationErrors;
159+
}
160+
}
161+
162+
/**
163+
* This error is thrown when an action's data (output) is invalid, i.e. when there's a mismatch between the
164+
* type of the data schema passed to `dataSchema` method and the actual return of the action.
165+
*/
166+
export class ActionOutputDataValidationError<DS extends Schema | undefined> extends Error {
167+
public validationErrors: ValidationErrors<DS>;
168+
169+
constructor(validationErrors: ValidationErrors<DS>) {
170+
super(
171+
"Invalid action data (output). Please be sure to return data following the shape of the schema passed to `dataSchema` method."
172+
);
173+
this.name = "ActionOutputDataError";
174+
this.validationErrors = validationErrors;
175+
}
176+
}

‎packages/next-safe-action/src/validation-errors.types.ts

+1-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import type { Infer, Schema } from "./adapters/types";
22
import type { Prettify } from "./utils.types";
33

4-
export type ValidationIssue = {
5-
message: string;
6-
path?: Array<string | number | symbol>;
7-
};
8-
94
// Object with an optional list of validation errors.
105
type VEList = Prettify<{ _errors?: string[] }>;
116

@@ -15,7 +10,7 @@ type SchemaErrors<S> = {
1510
} & {};
1611

1712
/**
18-
* Type of the returned object when input validation fails.
13+
* Type of the returned object when validation fails.
1914
*/
2015
export type ValidationErrors<S extends Schema | undefined> = S extends Schema
2116
? Infer<S> extends object

‎pnpm-lock.yaml

+29-29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎website/docs/safe-action-client/instance-methods.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ metadata(data: Metadata) => new SafeActionClient()
2828
## `schema`
2929

3030
```typescript
31-
schema(schema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient()
31+
schema(inputSchema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient()
3232
```
3333

3434
`schema` accepts an input schema of type `Schema` or a function that returns a promise of type `Schema` and an optional `utils` object that accepts a [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function. The schema is used to define the arguments that the safe action will receive, the optional [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function is used to return a custom format for validation errors. If you don't pass an input schema, `parsedInput` and validation errors will be typed `undefined`, and `clientInput` will be typed `void`. It returns a new instance of the safe action client.
@@ -41,6 +41,14 @@ bindArgsSchemas(bindArgsSchemas: BAS, bindArgsUtils?: { handleBindArgsValidation
4141

4242
`bindArgsSchemas` accepts an array of bind input schemas of type `Schema[]` and an optional `bindArgsUtils` object that accepts a `handleBindArgsValidationErrorsShape` function. The schema is used to define the bind arguments that the safe action will receive, the optional `handleBindArgsValidationErrorsShape` function is used to [return a custom format for bind arguments validation errors](/docs/recipes/customize-validation-errors-format). It returns a new instance of the safe action client.
4343

44+
## `outputSchema`
45+
46+
```typescript
47+
outputSchema(outputSchema: S) => new SafeActionClient()
48+
```
49+
50+
`outputSchema` accepts a schema of type `Schema`. That schema is used to define what the safe action will return. If you don't pass an output schema when you're defining an action, the return type will be inferred instead. If validation fails, an `ActionOutputDataValidationError` is internally thrown. You can catch it inside [`handleReturnedServerError`](/docs/safe-action-client/initialization-options#handlereturnedservererror)/[`handleServerErrorLog`](/docs/safe-action-client/initialization-options#handleservererrorlog) and access the `validationErrors` property to get the validation errors. It returns a new instance of the safe action client.
51+
4452
## `action` / `stateAction`
4553

4654
```typescript

‎website/docs/types/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export type SafeActionUtils<
246246

247247
### `ValidationErrors`
248248

249-
Type of the returned object when input validation fails.
249+
Type of the returned object when validation fails.
250250

251251
```typescript
252252
export type ValidationErrors<S extends Schema | undefined> = S extends Schema

‎website/src/components/landing/features.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ const features: { title: string; description: string }[] = [
2323
"Manage authorization, log and halt execution, and much more with a composable middleware system.",
2424
},
2525
{
26-
title: "Input validation using multiple validation libraries",
27-
description: `Input passed from the client to the server is validated using Zod, Valibot or Yup.`,
26+
title: "Input/output validation using multiple validation libraries",
27+
description: `Input and output are validated using your favorite library.`,
2828
},
2929
{
3030
title: "Advanced server error handling",

‎website/src/components/landing/hero.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export function Hero() {
1818
</h1>
1919
<h2 className="text-zinc-700 dark:text-zinc-300 font-medium text-base sm:text-lg md:text-xl max-w-xl">
2020
next-safe-action handles your Next.js app mutations type
21-
safety, input validation, server errors and even more!
21+
safety, input/output validation, server errors and even
22+
more!
2223
</h2>
2324
</div>
2425
<div className="flex justify-center items-center">

0 commit comments

Comments
 (0)
Please sign in to comment.