Skip to content

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 ​

ts
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 required or default.
  • The type of every rule. email, string and like give string, numeric rules give number, list_of gives an array, nested_object an object, or and variable_object a union.
  • Literal types. one_of: ['admin', 'user'] gives 'admin' | 'user', eq: 'active' gives 'active'.
  • Defaults. { default: 0 } makes the field required and gives number.
  • 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:

ts
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.

ValidatorSchema isType of the validated data
LIVRplain datainferred from the schema (InferFromSchema)
Zodcodeinferred (z.infer)
Valibotcodeinferred (InferOutput)
AjvJSON Schema or JTD datainferred for JTD (JTDDataType); for JSON Schema you write the type, and Ajv checks the schema against it
Joicodenot built in
fastest-validatorobject with code generationnot 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).

More ​