Validation
mion validates with @mionjs/run-types, which reads your TypeScript types at compile time and uses them at runtime.
You write no schemas and no validation code.
Automatic Validation
The params of every route and middleware are always validated before it runs.
If validation fails, the method never runs and an RpcError is thrown with the validation errors.
import {createMionRouter, Routes} from '@mionjs/router';
const mion = createMionRouter();
// Your TypeScript types ARE the validation schema
interface User {
id: string;
email: string;
age: number;
birthDate: Date;
tags: Set<string>;
}
type NewUser = Omit<User, 'id'>;
const routes = {
createUser: mion.route((ctx, user: NewUser): User => {
// Date and Set were restored from JSON, then user was validated
console.log(user.birthDate instanceof Date); // true
console.log(user.tags instanceof Set); // true
return {id: 'USER-123', ...user};
}),
} satisfies Routes;
Validation Errors
An invalid request throws a ValidationError, which extends RpcError.
import {RpcError, RunTypeError, ValidationError} from '@mionjs/core';
const validationError: ValidationError = new RpcError({
statusCode: 422,
type: 'validation-error',
publicMessage: "Invalid params in 'createUser', validation failed.",
errorData: {
typeErrors: [
{path: ['email'], expected: 'string'},
{path: ['age'], expected: 'number'},
] as RunTypeError[],
},
});
Validation Functions
Fast Boolean Check
createValidateFn builds a fast check that returns only true or false. Use it in guard clauses and hot paths, where you do not need error details.
import {createValidateFn} from '@mionjs/run-types';
interface User {
name: string;
age: number;
}
// synchronous: the mion vite plugin injects the compiled validator at build time
const validate = createValidateFn<User>();
validate({name: 'John', age: 30}); // true
validate({name: 'John'}); // false (missing age)
validate({name: 'John', age: '30'}); // false (age is string)
Detailed Error Information
createGetValidationErrorsFn builds a function that returns each error with the path to the invalid property and the expected type. Use it for user feedback (forms, API error responses), debugging and logging.
import {createGetValidationErrorsFn} from '@mionjs/run-types';
interface User {
name: string;
age: number;
}
// synchronous: returns the compiled error collector
const getUserErrors = createGetValidationErrorsFn<User>();
const errors = getUserErrors({name: 123, age: 'invalid'});
// [{path: ['name'], expected: 'string'}, {path: ['age'], expected: 'number'}]
Undeclared Properties
The parser option picks what happens to a property your type does not declare. Dropping happens while the request is decoded, not at validation.
import {createMionRouter, Routes} from '@mionjs/router';
interface User {
name: string;
email: string;
age: number;
}
// every route on this router rejects params carrying a property the type does not declare
const mion = createMionRouter({parser: {params: 'mutateStrict'}} as const);
const routes = {
// inherits mutateStrict from the router
createUser: mion.route((ctx, user: User): User => user),
// this route drops the extra property instead of refusing the request
updateUser: mion.route((ctx, user: Partial<User>): Partial<User> => user, {
parser: {params: 'clone'},
}),
} satisfies Routes;
mion.initRoutes(routes);
| Strategy | An undeclared property is |
|---|---|
clone (default) | dropped |
compact | dropped |
mutate | kept |
mutateStrict | rejected: the route never runs and the caller gets a validation-error (HTTP 422) |
mutateStrict works on params only. Parser Strategies shows what each strategy does to a return value.Unions
A union is the one shape where dropping is not enough. Its members share one list of property names, and a member with an index signature declares every property:
type Pet = {kind: 'cat'; meows: boolean} | {kind: 'dog'; barks: number};
// {kind: 'cat', meows: true, barks: 3} -> barks is kept
type Something = {a: string} | Record<string, number>;
// {a: 'x', evil: 'garbage'} -> the whole object is kept
type Loose = {a: string} | Record<string, string>;
// {a: 'x', evil: 'garbage'} -> accepted, every value is a string
Every strategy checks this: clone, compact and mutateStrict reject the request, mutate still keeps the property.
The check runs only when the union has two or more members with named properties, so {a: string} | number is not affected.
Checking Types Outside a Route
checkUnknowns rejects an undeclared property anywhere in the type, checkUnionUnknowns only on a union member. mutateStrict uses the first, clone and compact the second. See Checking for Unknown Keys.
import {createGetValidationErrorsFn, createValidateFn} from '@mionjs/run-types';
type Address = {street: string; city: string};
type User = {id: number; name: string; address: Address};
// one function, one pass: matches User and carries no extra property, at every level
const isUserStrict = createValidateFn<User>(undefined, {checkUnknowns: true});
isUserStrict({id: 1, name: 'Ada', address: {street: 'Main', city: 'Rome'}}); // true
isUserStrict({
id: 1,
name: 'Ada',
address: {street: 'Main', city: 'Rome'},
admin: true,
}); // false
isUserStrict({
id: 1,
name: 'Ada',
address: {street: 'Main', city: 'Rome', zip: '00184'},
}); // false, the extra is nested
// same option on the error report: one entry per undeclared key, `expected: 'never'`
const userErrors = createGetValidationErrorsFn<User>(undefined, {
checkUnknowns: true,
});
userErrors({
id: 1,
name: 'Ada',
address: {street: 'Main', city: 'Rome', zip: '00184'},
});
// [{path: ['address', 'zip'], expected: 'never'}]
// a union needs its own option: its members share one list of property names
type Pet = {kind: 'cat'; meows: boolean} | {kind: 'dog'; barks: number};
type Something = {a: string} | Record<string, number>;
const isPetUnionStrict = createValidateFn<Pet>(undefined, {
checkUnionUnknowns: true,
});
isPetUnionStrict({kind: 'cat', meows: true}); // true
isPetUnionStrict({kind: 'cat', meows: true, barks: 3}); // false, barks belongs to Dog
const isSomething = createValidateFn<Something>(undefined, {
checkUnionUnknowns: true,
});
isSomething({a: 'x'}); // true
isSomething({p: 1, q: 2}); // true, it is a record
isSomething({a: 'x', evil: 'garbage'}); // false, it matches neither member
export {isUserStrict, userErrors, isPetUnionStrict, isSomething};
Sanitizing Params
sanitizeParams is off by default. Turn it on and a route applies every transform its params types declare: trim spaces, lowercase an email, strip the grouping from a card number. It runs after decoding and before validation, so a value that passes only once trimmed is accepted and the handler gets the clean value. Headers and return values are never sanitized.
import {createMionRouter, Routes} from '@mionjs/router';
import type * as TF from '@mionjs/run-types/formats';
// The type says what a clean value looks like: trimmed and lowercased.
type Email = TF.Transform<TF.Email, {trim: true; lowercase: true}>;
// Turn sanitizing on for every route, or per route below. It is off by default.
const mion = createMionRouter({sanitizeParams: true});
const routes = {
// ' [email protected] ' arrives, the handler gets '[email protected]'
login: mion.route(
(ctx, email: Email, password: string): boolean =>
email.length > 0 && password.length > 0
),
// this route opts out: the handler gets the value exactly as sent
echo: mion.route((ctx, email: Email): string => email, {
sanitizeParams: false,
}),
} satisfies Routes;
mion.initRoutes(routes);
Sanitizing on the Client
On a sanitized route, the client applies the same transforms before its own validation and before sending, so both ends validate and send the same value.
import {initClient} from '@mionjs/client';
import type {MyApi} from './sanitize.routes.ts';
// sanitizeParams is on by default
const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
// ' [email protected] ' is trimmed and lowercased before it leaves the browser
const [loggedIn] = await routes
.login(' [email protected] ', 'my-password')
.call();
console.log(loggedIn); // true
// false: validate and send the value as typed; the server still sanitizes
const raw = initClient<MyApi>({
baseURL: 'http://localhost:3000',
sanitizeParams: false,
});
replaceAll and stripSeparators do, replace (first match only) does not.