Skip to content

Issues & Errors

Issue = IssueItem | IssueGroup is the structured failure record validup produces. They're discriminated by type, recursive (groups can wrap groups), and carry a structured payload (data) so the message can be re-rendered later in another locale.

IssueItem

IssueItem is a discriminated union over three branches keyed on the vocabulary code. The defineIssueItem factory uses TypeScript overloads to enforce the right data shape per code at compile time:

typescript
// Parameterized — `data` required and typed
type IssueItemTyped = {
    type: 'item';
    code: 'min_length';                  // (or another parameterized vocabulary code)
    data: { min: number };             // typed per `IssueDataByCode`
    path: PropertyKey[];
    message: string;
    received?: unknown;
    expected?: unknown;
    meta?: Record<string, unknown>;
};

// Bare — `data` must be absent
type IssueItemBare = {
    type: 'item';
    code: 'email';                       // (or another param-less vocabulary code)
    data?: undefined;
    path: PropertyKey[];
    message: string;
    /* …received / expected / meta… */
};

// Raw — ad-hoc string code, open `data`
type IssueItemRaw = {
    type: 'item';
    code: string & {};                   // anything outside the vocabulary
    data?: Record<string, unknown>;
    /* …same surrounding fields… */
};

type IssueItem = IssueItemTyped | IssueItemBare | IssueItemRaw;

Consumers usually don't write these out — narrow on code and TypeScript picks the right branch:

typescript
import { IssueCode, isIssueItem, flattenIssueItems } from 'validup';

for (const issue of flattenIssueItems(err.issues)) {
    if (issue.code === IssueCode.MIN_LENGTH) {
        // issue.data.min is typed as `number` after narrowing
        console.log(`min: ${issue.data?.min}`);
    }
}

IssueGroup

typescript
interface IssueGroup {
    type: 'group';
    code?: IssueCode | (string & {}); // e.g. IssueCode.ONE_OF_FAILED
    path: PropertyKey[];
    message: string;
    issues: Issue[]; // recursive
    data?: Record<string, unknown>;
    meta?: Record<string, unknown>;
}

Constructing issues

Always use the factories — they set type, default the code to VALUE_INVALID when omitted, and gatekeep the data shape per code:

typescript
import { defineIssueItem, defineIssueGroup, IssueCode } from 'validup';

// Bare code — no data accepted (default fallback)
const fallback = defineIssueItem({
    path: ['email'],
    message: 'Invalid email address',
    received: 'not-an-email',
});

// Parameterized code — `data` required and typed
const tooShort = defineIssueItem({
    code: IssueCode.MIN_LENGTH,
    path: ['password'],
    message: 'Password must be at least 12 characters',
    data: { min: 12 },                  // type-checked against IssueDataByCode
});

// Ad-hoc string code — open `data`
const taken = defineIssueItem({
    code: 'email_taken',
    path: ['email'],
    message: 'Email already in use',
    data: { existingUserId: 'u_42' },
});

const group = defineIssueGroup({
    path: ['credentials'],
    message: 'Credentials are invalid',
    issues: [fallback, tooShort, taken],
});

The same gatekeep applies to the createValidupError sugar — createValidupError(value, IssueCode.MIN_LENGTH, msg) is a compile error (missing { min }); createValidupError(value, IssueCode.EMAIL, msg, { … }) is a compile error (EMAIL is bare).

meta conventions

meta is an open Record<string, unknown> because issues travel across package boundaries — integration adapters, frameworks, and apps each have provenance the core can't know about. To keep the bag focused, library-owned meta keys are limited to provenance the consumer cannot reconstruct from path + container config. Presentation tokens (e.g. severity) and reconstructible facts (e.g. the active group) don't qualify.

Two keys are library-owned today:

KeySet byWhat it means
optional?: trueCore runtime, when the originating mount's optional declaration resolves truthy for the current value. optional: true always tags; optional: false and undefined never do; optional: (v) => boolean is invoked with the value and tags iff truthy (in practice always false at error time, since the validator would have been skipped otherwise).The mount permits this field being skipped. Useful for downgrading UX severity (e.g. show a warning instead of an error when an optional field's content is invalid).
external?: trueFrameworks injecting server-side issues (e.g. @validup/vue's setExternalIssues).Distinguishes server-supplied from validator-supplied so themes can render the distinction.

meta.optional — no inheritance

meta.optional reflects only the most-local mount's config. Inside a child container mounted as optional, the child's own mounts retain their own optional status independently:

typescript
const role = new Container<{ name: string }>();
role.mount('name', stringValidator);                  // required inside Role

const user = new Container();
user.mount('role', { optional: true }, role);         // role itself is optional

await user.run({ role: { name: 42 } });               // throws ValidupError
// - issue at ['role']: type 'group', meta: { optional: true }   ← parent's optional config
// - leaf at ['role', 'name']: meta: undefined                   ← child's own mount was required

The rationale: "role is optional" means you don't have to provide a role. Once you do, the role's own required fields stay required. The wrapping group at ['role'] carries the parent's optional flag; the leaf at ['role', 'name'] does not, because its own mount declared no optional config.

Apps and third-party validators may add their own meta keys (e.g. meta.componentId: 'role-name-input') — the open shape is intentional. Naming clashes are the responsibility of the producer.

ValidupError

typescript
class ValidupError extends BaseError {
    readonly code = 'VALIDUP_ERROR';
    readonly issues: Issue[];
    toJSON(): unknown;
}

ValidupError extends @ebec/core's BaseError. It carries a code, an optional cause, the collected issues, and a toJSON() overridden to include issues. The .message is auto-built from issue paths — useful as a default human-readable summary.

isValidupError

Use the duck-type guard rather than instanceof — it works across realm boundaries (e.g. multiple bundled copies of the package, or thrown from a worker):

typescript
import { isValidupError } from 'validup';

try {
    await container.run(data);
} catch (e) {
    if (isValidupError(e)) {
        for (const issue of e.issues) {
            console.error(issue.path.join('.'), '→', issue.message);
        }
    }
}

The guard returns true if e instanceof ValidupError or if e.issues is a valid array.

Helpers

typescript
import {
    isIssue, isIssueItem, isIssueGroup,
    flattenIssueItems, flattenIssueGroups,
    formatIssue,
    buildErrorMessageForAttribute, buildErrorMessageForAttributes,
    stringifyPath,
} from 'validup';
HelperPurpose
isIssue(x)Discriminate any value
isIssueItem(x)Narrow to IssueItem
isIssueGroup(x)Narrow to IssueGroup
flattenIssueItems(issues)Recurse into groups, return only the leaf items
flattenIssueGroups(issues)Recurse, return only the groups
formatIssue(issue, templates?)Re-render message using data against your templates
buildErrorMessageForAttribute('email')'Property "email" is invalid.'
stringifyPath([...])Render a PropertyKey[] as 'a.b[0].c'

Issue code vocabulary

validup ships a vocabulary of well-known issue codes that adapter packages (@validup/zod, @validup/validator-js) map onto and that i18n catalogs (@ilingo/validup) translate from. The vocabulary tracks the common ground across vuelidate, zod, joi, and yup — enough that a translation catalog can ship one localized string per code instead of a generic "invalid value" fallback.

ThemeCodeWhendata
Generic / structuralVALUE_INVALIDDefault for any defineIssueItem(...) without a code
ONE_OF_FAILEDAll branches of a oneOf container failed
PresenceREQUIREDValue is missing, undefined, null, or empty
Type assertionsALPHAValue contains characters outside the alphabetical set
ALPHA_NUMValue contains characters outside the alphanumeric set
NUMERICValue is not a number
INTEGERValue is not an integer
DECIMALValue is not a decimal number
LengthMIN_LENGTHValue is shorter than the configured minimum{ min: number }
MAX_LENGTHValue is longer than the configured maximum{ max: number }
Numeric rangeMIN_VALUENumeric value is below the configured minimum{ min: number }
MAX_VALUENumeric value is above the configured maximum{ max: number }
BETWEENNumeric value falls outside [min, max]{ min: number, max: number }
String formatEMAILValue is not a valid email address
URLValue is not a valid URL
IP_ADDRESSValue is not a valid IP address
MAC_ADDRESSValue is not a valid MAC address
UUIDValue is not a valid UUID
DATEValue is not a valid / parseable date
PATTERNValue does not match the expected regex{ pattern: string }
JSONValue is not valid JSON
BASE64Value is not valid base64
STRONG_PASSWORDValue doesn't meet the configured strength rules{ minLength?, minLowercase?, minUppercase?, minNumbers?, minSymbols? }
ComparisonSAME_ASValue must equal another named field's value (e.g. password-confirm){ other: string }

Adapters are responsible for mapping foreign codes onto the vocabulary. When a foreign code has no direct match, the adapter falls back to IssueCode.VALUE_INVALID and the consumer-side template uses the eagerly-rendered English issue.message.

Custom codes

IssueItem.code is widened to IssueCode | (string & {}), so any literal string works at runtime:

typescript
defineIssueItem({ code: 'email_taken', path: ['email'], message: 'Already taken.' });
// → accepted; downstream code paths treat it like any other code

For typed autocomplete on project-specific codes, define your own const that spreads the shipped vocabulary:

typescript
import { IssueCode } from 'validup';

export const AppCode = {
    ...IssueCode,
    EMAIL_TAKEN: 'email_taken',
    RATE_LIMITED: 'rate_limited',
} as const;

export type AppCode = typeof AppCode[keyof typeof AppCode];

throw new ValidupError([
    defineIssueItem({
        code: AppCode.EMAIL_TAKEN,
        path: ['email'],
        message: 'Email already in use.',
    }),
]);

Typed data for custom codes

IssueDataByCode is an interface, so consumers can augment it via TypeScript declaration merging — defineIssueItem / createValidupError then gatekeep the custom code the same way they do the built-ins:

typescript
// Anywhere in your codebase (e.g. an ambient `app-types.d.ts`):
declare module 'validup' {
    interface IssueDataByCode {
        email_taken: { existingUserId: string };
        rate_limited: { retryAfterMs: number };
    }
}

// At a producer call site:
defineIssueItem({
    code: 'email_taken',
    path: ['email'],
    message: 'Already in use',
    data: { existingUserId: 'u_42' },   // ✓ typed and required
});

defineIssueItem({
    code: 'email_taken',
    path: ['email'],
    message: 'Already in use',
    // @ts-expect-error — data required for a parameterized code
});

Augment only with codes you own — adding entries you don't control collides with future vocabulary expansions and other consumers' merges.

Released under the Apache 2.0 License.