ApiError
Public API, RPC, Socket and loader error, delivered to the client with its code, status and, since 1.5, field messages.
Se autoimporta en el código de servidor: server/ y <script server>.
import { ApiError } from "@meekojs/meeko/server";new ApiError(code, status, mensaje?) is the error the server can disclose to the client. When thrown from an endpoint, RPC procedure, Socket channel, loader or defer, the response carries its code and status, plus the request's requestId. Any other error is returned as INTERNAL_ERROR/500 without exposing its message.
Since 1.5, the third argument also accepts { message, issues }: issues contains public field messages, shaped like those from a validation 400, which useForm displays beside each field. message is always internal.
It is auto-imported in server code.
Usage#
// server/rpc/users/get.ts
import { ApiError, defineRpc } from "@meekojs/meeko/server";
import { userParams, userSchema } from "../../../shared/schemas/user";
export default defineRpc({
input: userParams,
output: userSchema,
handler: async ({ input }) => {
const user = await findUser(input.id);
if (!user) throw new ApiError("USER_NOT_FOUND", 404);
return user;
},
});The response is 404 with { "code": "USER_NOT_FOUND", "status": 404, "requestId": "…" }, and rpc.users.get(…) rejects with a ClientError with that code and status.
Type#
class ApiError extends Error {
readonly issues?: readonly ApiErrorIssue[];
constructor(code: string, status: number, message?: string | ApiErrorOptions);
}Parameters#
| Parameter | Type | Default | Description |
|---|---|---|---|
code |
string |
required | Stable public code the client can compare: "NOT_FOUND", "EMAIL_TAKEN". |
status |
number |
required | HTTP response status, usually a 4xx. |
message |
string or { message?, issues? } |
code |
The internal message, which is never sent. Since 1.5, issues: [{ path, message }] adds public field messages. |
Each issue needs a string path (keys joined with ., or "" for the whole form) and a nonempty message.
Return value#
An Error instance with name: "ApiError", code, status, message and issues. What reaches the client:
| Field | Transferred | Notes |
|---|---|---|
code, status |
Yes | Plus requestId and the x-request-id header. |
issues |
Only with a 4xx status |
At most 20. |
message |
Never | Use it for server logs. |
Examples#
A field conflict#
// server/api/signup.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { signupInput, signupResult } from "../../shared/schemas/signup";
export default defineApiRoute({
body: signupInput,
response: signupResult,
status: 201,
handler: async ({ body }) => {
if (await emailTaken(body.email)) {
throw new ApiError("EMAIL_TAKEN", 409, {
message: `duplicate key for ${body.email}`, // interno
issues: [{ path: "email", message: "That email already has an account." }],
});
}
return createUser(body);
},
});On the client, useForm's form.errors.value.email is “That email already has an account.”.
A 404 from a loader#
An ApiError with status 404 in a loader shows the nearest error page with { status: 404, message: "NOT_FOUND" }:
// app/pages/products/[slug].route.ts
import { definePage } from "@meekojs/meeko/framework";
import { ApiError } from "@meekojs/meeko/server";
export const route = definePage({
load: async ({ params }) => {
const product = await catalog.find(params.slug);
if (!product) throw new ApiError("NOT_FOUND", 404);
return { name: product.name };
},
});Permission errors from locals#
locals: async ({ session }) => {
const user = await findUser((session.current!.data as { userId: string }).userId);
if (!user) throw new ApiError("UNAUTHENTICATED", 401);
if (!user.active) throw new ApiError("ACCOUNT_DISABLED", 403);
return { user };
},Common mistakes#
Putting private data in the code or issues#
code and issues reach the browser; message does not. Private data in the public part leaks without any error.
throw new ApiError(`USER_${user.email}_LOCKED`, 403);throw new ApiError("ACCOUNT_LOCKED", 403, { message: `locked: ${user.email}` });An issue without message#
throw new ApiError("INVALID", 409, { issues: [{ path: "email" }] });meeko check reports TS2741: Property 'message' is missing in type '{ path: string; }' but required in type 'ApiErrorIssue'. Without types, creating the error throws TypeError: Each ApiError issue needs a string path and a nonempty string message.
throw new ApiError("INVALID", 409, { issues: [{ path: "email", message: "Check the email address." }] });Throwing a regular Error expecting it to reach the client#
throw new Error("That email already has an account");The response is 500 INTERNAL_ERROR, without the message, and the server logs it as an internal failure.
throw new ApiError("EMAIL_TAKEN", 409, { issues: [{ path: "email", message: "That email already has an account." }] });Notes#
- Security. This is the boundary between what the server knows and what it discloses. Write
issuesfor the user and leave details inmessage, which never leaves the server. 4xxand5xx.issuesare only transferred with a4xx: a5xxis a server failure and responds without them.4xxresponses are not logged as internal failures.- Other copies. An
ApiErrorfrom another module graph (Vite) preserves itsissues. A similarly shaped error from another source (an SDK'sApiError, aClientErrorfrom another service) does not send itsissues. - Where it is not used. In a component,
ApiErrorisMEEK108: the browser receives errors asClientError.