Skip to content

Results and issues

Validating a body gives you a result object. When it passes, the result is { ok: true, data }, where data is the accepted input. When it fails, it’s { ok: false, issues }, where issues is an array with one entry per problem. Each issue has a code saying what kind of problem it is, a path saying where, a human-readable message, and for Mongoose rule failures, a rule naming the validator.

type GuardResult<TData = Record<string, unknown>> =
| { ok: true; data: TData }
| { ok: false; issues: Issue[] };

Check ok first. TypeScript then knows which of the two shapes you have:

const result = await signupGuard.validate(body);
if (!result.ok) {
return res.status(400).json({ errors: result.issues });
}
await User.create(result.data);

The Express adapter does this for you. Valid data goes to req.validated, and issues become a 400 response.

interface Issue {
code: IssueCode;
path: string;
message: string;
rule?: string;
}
FieldExampleMeaning
code"forbidden_field"The kind of problem. One of six fixed values, listed below. Safe to branch on in code.
path"addresses.1.city"Where in the body the problem is. "" means the body itself.
message"Field is not allowed"A human-readable description, in English. Fine for logs and developer-facing APIs.
rule"minlength"Only on invalid_value issues: which Mongoose validator failed.
CodeMeansExample message
invalid_bodythe body isn’t a JSON objectExpected a JSON object
unsafe_keya prototype key, $ key or dotted keyKeys starting with $ are not allowed
unknown_fieldthe key isn’t in the schemaUnknown field
forbidden_fieldthe key is in the schema but not in allowField is not allowed
invalid_typethe value has the wrong JavaScript typeExpected number, received string
invalid_valuea Mongoose validator rejected the valuePath `age` (5) is less than minimum allowed value (13).

Issue codes lists every message each code can produce.

Paths use dots between levels and numbers for array positions, the same style Mongoose uses in its own errors.

BodyProblemPath
{ "nmae": "Amrit" }typo at the top levelnmae
{ "address": { "city": "Delhi", "hacked": true } }unknown key inside a sub-schemaaddress.hacked
{ "tags": ["js", 42] }second array element is a numbertags.1
{ "addresses": [{ "city": "Delhi" }, {}] }second address has no cityaddresses.1.city
[ ... ]the body is an array""

message is written for developers. For a form shown to end users, map code, path and rule to your own text:

const labels: Record<string, string> = { name: "Name", email: "Email", password: "Password" };
function toFormErrors(issues: Issue[]) {
const errors: Record<string, string> = {};
for (const issue of issues) {
const label = labels[issue.path] ?? issue.path;
if (issue.rule === "required") errors[issue.path] = `${label} is required`;
else if (issue.rule === "minlength") errors[issue.path] = `${label} is too short`;
else errors[issue.path] = `${label} is invalid`;
}
return errors;
}

You can also set friendlier messages directly in your Mongoose schema, and mongoose-guard passes them through unchanged:

name: { type: String, minLength: [2, "Name must be at least 2 characters"] },
{ "code": "invalid_value", "path": "name", "message": "Name must be at least 2 characters", "rule": "minlength" }

data is a new object holding exactly the fields that were accepted, with their original values. Nothing is cast, no defaults are added, and setters such as lowercase haven’t run yet. In reject mode, data has the same content as the body you passed in. In strip mode, the unknown and forbidden fields are gone.

Your input object is never modified.