TypeScript
Inferring types from a schema
Infer<D> is the TypeScript type of a value check and parse accept for the definition D, computed from the definition's type alone, with nothing generated. Schema.from and new Schema keep the definition as a type parameter (Schema<D>), and InferSchema<typeof schema> reads it:
import { Schema, type InferSchema } from '@alexify/metaschema';
const user = Schema.from({
name: 'string',
age: '?number',
role: { enum: ['admin', 'user'] },
tags: { array: 'string' },
address: { city: 'string', 'street?': 'string' },
point: ['number', 'number'],
});
type User = InferSchema<typeof user>;
// {
// name: string;
// age?: number | null | undefined;
// role: 'admin' | 'user';
// tags: string[];
// address: { city: string; street?: string | null | undefined };
// point: [number, number];
// }The literal types of the definition are what Infer reads, so write the definition inline, as above, or declare it as const. A definition held in a plain const widens 'string' to string and ['admin', 'user'] to string[], and every field infers as unknown:
import type { Infer } from '@alexify/metaschema';
const definition = { name: 'string', role: { enum: ['admin', 'user'] } } as const;
type User = Infer<typeof definition>; // { name: string; role: 'admin' | 'user' }| Definition | Infer |
|---|---|
'string', 'number', 'boolean', 'bigint' | the scalar |
'integer' | number |
'date', 'null', 'any', 'unknown' | Date, null, any, unknown |
'bytes' | Uint8Array |
'json' | unknown |
{ flags: ['read', 'write'] } | { read?: boolean; write?: boolean } |
'?T', { type: 'T', required: false } | T | null | undefined; in a struct, an optional property |
'key?': 'T' | an optional property: key?: T | null | undefined |
{ type: 'T', nullable: true } | T | null |
{ type: 'T', ...options } | T; the options do not change the type |
{ type: 'T', default: x } | T; in a struct, an optional property (parse fills it in) |
{ type: 'T', parse: (value) => R } | T on the input side; R in InferOutput |
{ enum: ['open', 'done'] } | 'open' | 'done' |
{ array: T }, { set: T } | T[], Set<T> |
{ object: { string: T } }, { map: { number: T } } | Record<string, T>, Map<number, T> |
['number', 'string'], [{ x: 'number' }, { 'y?': 'number' }] | [number, string], [number, number | null | undefined] |
{ union: [A, B] } | A | B; a discriminated union is a union of its structs |
a nested struct, { schema: {...} }, a Schema instance | an object type |
'Company', { one: 'Company' }, { type: 'Company' } | string, the id |
{ many: 'Address' } | string[] |
{ type: 'Company', embed: true } | unknown, or the record with an entity map |
| a custom type | its entry in CustomTypes, else unknown |
A kind key (Entity: {}), an index definition ({ unique: [...] }), a calculated field (a function) and the schema-level validate, parse, serialize and format are not fields and do not appear in the type.
What Infer reads
TypeScript keeps no key order, so where the runtime reads the first key, Infer reads the keys that are there: an object with a kind key is a struct; otherwise one with a type string is the long form, one with a collection key (array, set, object, map, enum, tuple, union, schema, one, many) is that shorthand, and anything else is a struct. The two agree on every definition the syntax guide shows, and differ on a struct with a field named type or like a collection type that is not its first field: { name: 'string', type: 'string' } validates as a struct and infers as string. A kind settles it for both:
const part = Schema.from({ Struct: {}, type: { enum: ['cpu', 'ram'] }, name: 'string' });
type Part = InferSchema<typeof part>; // { type: 'cpu' | 'ram'; name: string }Custom types
Infer knows a custom type through the CustomTypes interface, extended by module augmentation; a type name that is neither built in nor declared there infers as unknown:
// The types of the Custom Types guide: `datetime` is `{ js: 'string' }`,
// `decimal` validates a string with its own checkType.
declare module '@alexify/metaschema' {
interface CustomTypes {
datetime: string;
decimal: string;
}
}
const payment = Schema.from({ amount: 'decimal', createdAt: 'datetime', clientIp: '?ip' });
type Payment = InferSchema<typeof payment>;
// { amount: string; createdAt: string; clientIp?: unknown }An alias of schema in the long form ({ type: 'address', schema: { city: 'string' } }) infers as the struct it holds.
References and entity maps
Without a model Infer cannot see what a reference points at, so it follows check outside a model: every reference is an id (string, string[] for many) and embed: true is unknown. Infer<D, E> takes an entity map E, the entities as an object by name, and applies the rule of check: a stored kind is its id, a memory kind is the record, embed on the field overrides, and a projection has its parent's fields. InferEntity<E, 'Name'> is one entity of the map with the id field its kind adds (personId?: string for a stored Person), which Infer<D> and InferSchema leave out because a definition does not carry the entity's name:
import { Model, type Infer, type InferEntity } from '@alexify/metaschema';
const entities = {
Company: { Registry: {}, name: 'string' },
Tag: { Struct: {}, label: 'string', parent: '?Tag' },
Person: { Entity: {}, name: 'string', employer: 'Company', tags: { many: 'Tag' } },
} as const;
type Tag = Infer<'Tag', typeof entities>;
// { label: string; parent?: Tag | null | undefined }
type Person = InferEntity<typeof entities, 'Person'>;
// { name: string; employer: string; tags: Tag[]; personId?: string }
const model = new Model({}, new Map(Object.entries(entities)));The entities of a model are plain Schema instances (model.entities.get('Person') is a Schema | undefined and infers nothing); the entity map is the typed source.
The output of parse
InferOutput<D> is the type of the value parse gives: Infer<D> with the result of every parse function (a field's decoder, a nested struct's, the schema's own) in place of the value it decodes (Awaited, for an async decoder), and every key with a default present. parse returns ParseResult<InferOutput<D>>, which narrows on valid, and serialize takes an InferOutput<D> and gives an Infer<D>:
import { Schema, type Infer, type InferOutput } from '@alexify/metaschema';
const event = Schema.from({
name: 'string',
'level?': { type: 'integer', default: 1 },
when: { type: 'string', parse: (text: string) => new Date(text) },
});
type EventInput = Infer<typeof event>;
// { name: string; level?: number | null | undefined; when: string }
type Event = InferOutput<typeof event>;
// { name: string; level: number | null; when: Date }
const result = event.parse(body);
if (result.valid) result.value.when.getTime(); // result.value is EventA defaulted key is optional on the input side, because parse fills it, and present on the output side; where the key was declared optional, null stays in its type (a given null is not defaulted) and undefined goes. InferOutput<D, E> takes an entity map like Infer.
Infer and the generated interfaces
Both follow the same rules, with three differences. The dts names a stored reference after its column (employerId: string), where check reads the field (employer) and Infer types it as such. The dts renders an optional field as street?: string, where check also accepts null and Infer says string | null | undefined. And the dts has every field with a default, which Infer lets a value leave out (the output side, InferOutput, has it too).
The typings need TypeScript 5.0 or later (const type parameters). The examples of this page are compiled by tests/types/infer.test-d.ts. A Schema<D> is also a Standard Schema whose input and output types are Infer<D>; see Standard Schema.
Generating interfaces from a model
toTypeScript(model) of the @alexify/metaschema/generators subpath renders every entity as a TypeScript interface, in dependency order (the subpath keeps the generators out of the core entry; see The generators subpath):
const { toTypeScript } = require('@alexify/metaschema/generators');
const model = new Model(types, entities);
console.log(toTypeScript(model));For the model in Domain Models this prints:
interface Address {
city: string;
street: string;
building?: string;
addressId?: string;
}
interface Company {
name: string;
addressesId: string[];
companyId?: string;
}
interface User {
login: string;
companyId: string;
active: boolean;
userId?: string;
}Rules of the conversion:
| Definition | TypeScript |
|---|---|
'string', 'number', 'boolean', 'bigint', and aliases of them ({ js: 'string' }) | the same scalar |
'integer' | number |
'date', 'null', 'any', 'unknown' | Date, null, any, unknown |
'bytes' | Uint8Array |
{ flags: ['read', 'write'] } | { read?: boolean; write?: boolean } |
{ union: [A, B] } | A | B |
{ enum: ['open', 'done'] } | "open" | "done" |
{ array: T } | T[] |
{ set: T } | Set<T> |
['number', 'number'] | [number, number] |
{ object: { string: T } } | Record<string, T> |
{ map: { number: T } } | Map<number, T> |
| a nested struct | an inline object: { city: string; zip?: string } |
{ type: T, nullable: true } | T | null |
'json' | unknown |
a custom type with its own checkType | string |
company: 'Company', a stored kind | an id: companyId: string |
addresses: { many: 'Address' }, a stored kind | ids: addressesId: string[] |
label: 'Tag', a memory kind | the interface: label: Tag (tags: Tag[] for many) |
{ type: 'Company', embed: true } / { type: 'Tag', embed: false } | the record / the id, whatever the kind |
{ type: T, description: 'text' } | a JSDoc comment above the member: /** text */ |
{ type: T, deprecated: true } | a @deprecated tag in the JSDoc |
Optional fields get ?, and stored kinds include their own id field (userId?: string). Whether a reference is an id or the interface follows the storage view and graph view rule of check; a reference toTypeScript cannot resolve (outside a model) renders as an id.
toTypeScript(schema) renders a single schema the same way; a schema whose definition is a single type renders as a type alias (type Pair = [number, string];). After register() of the subpath the same renderings are model.toTypeScript(options), model.dts and schema.toInterface(options).
JSDoc and named types
The description of a field is the JSDoc of its member, and deprecated: true adds the @deprecated tag, so an editor shows both where the type is used. By default an enum renders as a union of literals and a nested struct as an inline object type; toTypeScript(model, { named: true }) gives each a type of its own, named after the entity and the field and rendered before the interface that uses it (toTypeScript(schema, options) takes the same option):
const entities = new Map([
['Company', { Dictionary: {}, name: { type: 'string', unique: true }, addresses: { many: 'Address' } }],
['Address', { Entity: {}, city: 'string', street: 'string', building: '?string' }],
['User', {
Registry: {},
login: { type: 'string', length: { min: 3, max: 32 }, description: 'The login name' },
role: { enum: ['admin', 'editor'] },
name: { first: 'string', 'last?': 'string' },
legacyId: { type: 'string', deprecated: true, required: false },
company: 'Company',
active: 'boolean',
}],
]);
console.log(toTypeScript(new Model(types, entities), { named: true }));interface Address {
city: string;
street: string;
building?: string;
addressId?: string;
}
interface Company {
name: string;
addressesId: string[];
companyId?: string;
}
type UserRole = "admin" | "editor";
interface UserName {
first: string;
last?: string;
}
interface User {
/** The login name */
login: string;
role: UserRole;
name: UserName;
/** @deprecated */
legacyId?: string;
companyId: string;
active: boolean;
userId?: string;
}Without the option role is "admin" | "editor" and name is { first: string; last?: string } inline. A name is the entity, then every key on the way to the field, capitalized (UserName, UserAddressGeo for a struct inside a struct); the element of a collection takes the name of its field (levels: { array: { enum: [1, 2] } } renders type DocLevels = 1 | 2 and levels: DocLevels[]), and the elements of a tuple or the branches of a union take an index (DocPair0, DocEither1). An inline object type has no room for JSDoc, so the description of a field of a nested struct appears only with named types.
Writing the file
saveTypes(outputFile, target, options?) of the generators subpath writes toTypeScript(target, options) to a file and returns a promise:
const { saveTypes } = require('@alexify/metaschema/generators');
await saveTypes('./types/model.d.ts', model);
await saveTypes('./types/model.d.ts', model, { named: true });It is a thin wrapper over fs.promises.writeFile, and the only API that touches the file system. In the browser it rejects.
Typings for the package
@alexify/metaschema ships hand-written declarations in index.d.ts. Besides the classes and functions, it exports a type for everything public: Infer, InferOutput, InferSchema, InferEntity, CustomTypes, Kind, Scope, Fields, FieldType, TypeEntry, ValidationIssue, ParseResult, DefinitionErrorCode and the rest listed in the API reference:
import { Schema, type Kind, type ValidationResult } from '@alexify/metaschema';
const schema = Schema.from({ name: 'string' });
const kind: Kind = schema.kind;
const result: ValidationResult = schema.check({ name: 'Marcus' });