Custom API shape with normalize
Adapt an API's own response envelope, or a library like next-safe-action, to ActionResult.
An action can return anything. normalize turns whatever it returned into an ActionResult,
so you adapt the shape once instead of reshaping every action. Use it for successful return values. Use
mapError for thrown errors.
An { ok, ... } envelope
Suppose the API never throws, and answers every request with a discriminated envelope:
import { defaultNormalize, type ActionResult } from "submitly/core";
type Envelope =
| { ok: true; data: unknown; notice?: string }
| { ok: false; error: { code: string; message: string; fields?: { path: string; message: string }[] } };
function isEnvelope(value: unknown): value is Envelope {
return typeof value === "object" && value !== null && typeof (value as { ok?: unknown }).ok === "boolean";
}
export function normalizeEnvelope(raw: unknown): ActionResult {
// Anything that is not an envelope keeps the built-in behaviour.
if (!isEnvelope(raw)) return defaultNormalize(raw);
if (raw.ok) {
return { success: true, message: raw.notice, data: raw.data };
}
const { message, fields = [] } = raw.error;
// Group the field list by path: several messages can target the same field.
const fieldErrors: Record<string, string[]> = {};
for (const field of fields) {
(fieldErrors[field.path] ??= []).push(field.message);
}
return {
success: false,
message,
errors: fields.length ? [] : [message],
fieldErrors,
};
}Install it for the whole app:
<CrudProvider normalize={normalizeEnvelope} /* ... */>or for one call, which wins over the provider:
useCrudForm({ schema, action: saveLegacyThing, normalize: normalizeEnvelope });Field paths must be dotted (items.0.name) to match react-hook-form field names. If the API uses another notation
(items[0].name), convert it in the adapter.
next-safe-action
next-safe-action defines the server action; submitly handles the form and the result.
Their action, our hooks. A safe action resolves to an object with data, serverError and validationErrors; teach
submitly that shape:
import type { ActionResult } from "submitly/core";
type SafeActionResult = {
data?: unknown;
serverError?: string;
// The default "formatted" shape: `_errors` at each level, nested objects per field.
validationErrors?: Record<string, unknown>;
};
function collect(node: unknown, path: string, out: Record<string, string[]>) {
if (typeof node !== "object" || node === null) return;
for (const [key, value] of Object.entries(node)) {
if (key === "_errors" && Array.isArray(value) && path) {
out[path] = [...(out[path] ?? []), ...(value as string[])];
} else if (key !== "_errors") {
collect(value, path ? `${path}.${key}` : key, out);
}
}
}
export function normalizeSafeAction(raw: unknown): ActionResult {
const result = (raw ?? {}) as SafeActionResult;
if (result.serverError) {
return { success: false, message: result.serverError, errors: [result.serverError] };
}
if (result.validationErrors) {
const fieldErrors: Record<string, string[]> = {};
collect(result.validationErrors, "", fieldErrors);
const rootErrors = (result.validationErrors as { _errors?: string[] })._errors ?? [];
return { success: false, message: "Validation failed", errors: rootErrors, fieldErrors };
}
return { success: true, data: result.data };
}const { form, onSubmit } = useCrudForm({
schema: productSchema, // the same schema your safe action uses
defaultValues: { name: "", price: "" },
action: (values) => createProductSafe(values), // a next-safe-action action
normalize: normalizeSafeAction,
});useCrudForm already validates with the schema on the client, so most validation errors never reach the server; the
mapping covers the rest. Because the safe action validates the form's parsed output again, the
schema idempotency rule applies here too.
This adapter is written against next-safe-action's documented default result shape. Check it against the version you
use, especially if you configured a custom defaultValidationErrorsShape.