Type inference
A LIVR schema is plain data, and TypeScript still knows exactly what validate() returns. You describe the rules once, and the type of the validated data follows from them: there is no interface to write next to the schema and keep in sync, and no code generator to run.
Type safety without a second definition is the priority → types are inferred from the schema data → schemas in code need
as const, and a schema loaded at runtime gets its type from wherever you declare it
One definition, two results
import LIVR from 'livr';
import type { InferFromSchema } from 'livr/types';
const schema = {
email: ['required', 'email'],
role: { one_of: ['admin', 'user'] },
address: {
nested_object: {
city: ['required', 'string'],
zip: 'positive_integer',
},
},
tags: { list_of: 'string' },
} as const;
type User = InferFromSchema<typeof schema>;
// {
// email: string;
// role?: 'admin' | 'user';
// address?: { city: string; zip?: number };
// tags?: string[];
// }
const validator = new LIVR.Validator<User>(schema);
const user = validator.validate(input); // User | false
if (user) {
user.address?.city; // string | undefined
}The same object is the validation rules at runtime and the source of the type at compile time. Change a rule, and the type changes with it. Open this example in the TypeScript Playground and hover over the types.
What gets inferred
- Required and optional fields. Fields are optional unless they have
requiredordefault. - The type of every rule.
email,stringandlikegivestring, numeric rules givenumber,list_ofgives an array,nested_objectan object,orandvariable_objecta union. - Literal types.
one_of: ['admin', 'user']gives'admin' | 'user',eq: 'active'gives'active'. - Defaults.
{ default: 0 }makes the field required and givesnumber. - Aliases and your own rules, once their output type is declared.
The built-in rules page lists the type of every rule, and the TypeScript guide has all the details.
Your own rules are typed too
A custom rule declares its output type once, by module augmentation, and every schema that uses it gets the right type:
import type { RuleTypeDef } from 'livr/types/inference';
declare module 'livr/types/inference' {
interface RuleTypeRegistry {
to_date: RuleTypeDef<Date, false, false>;
uuid: RuleTypeDef<string, false, false>;
}
}
const schema = {
id: ['required', 'uuid'],
created_at: ['required', 'to_date'],
} as const;
type Entity = InferFromSchema<typeof schema>; // { id: string; created_at: Date }Rules whose output depends on their arguments, and rule packages like livr-extra-rules, work the same way. See Custom rules type inference.
Data and types together
Other validators make you choose: schemas as code with inferred types, or schemas as data without them.
| Validator | Schema is | Type of the validated data |
|---|---|---|
| LIVR | plain data | inferred from the schema (InferFromSchema) |
| Zod | code | inferred (z.infer) |
| Valibot | code | inferred (InferOutput) |
| Ajv | JSON Schema or JTD data | inferred for JTD (JTDDataType); for JSON Schema you write the type, and Ajv checks the schema against it |
| Joi | code | not built in |
| fastest-validator | object with code generation | not built in |
With LIVR, the schema stays data that you can store, send and share with other languages, and TypeScript still types it.
The trade-off
Inference needs the schema literal in your code, with as const. A schema that arrives at runtime, from a database or an API, has no compile-time type, as with any validator. Declare its output type where you load it, and pass it to new LIVR.Validator<Type>(schema).