Generate TypeScript types from a Hyperschema.
Point it at the same schema instance you pass to Hyperschema.toDisk, and it writes a .d.ts file of interface/type declarations matching your structs, enums, aliases, arrays and records. You then import those types into your app to get static typing over the objects you encode and decode.
npm install hyperschema-ts
const Hyperschema = require('hyperschema')
const HyperschemaTS = require('hyperschema-ts')
const schema = Hyperschema.from('./schema')
const ns = schema.namespace('example')
ns.register({
name: 'request',
fields: [
{ name: 'id', type: 'uint', required: true },
{ name: 'name', type: 'string', required: true },
{ name: 'tags', type: 'string', array: true }
]
})
// Write the compact-encoding definitions (schema.json + index.js)
Hyperschema.toDisk(schema)
// Write the matching TypeScript declarations (types.d.ts)
HyperschemaTS.toDisk(schema)
This writes ./schema/types.d.ts:
// This file is autogenerated by hyperschema-ts
// Schema version: 1
// Do not edit manually.
export interface ExampleRequest {
id: number
name: string
tags?: string[] | null
}
which you import as type-only:
import type { ExampleRequest } from './schema/types'
const req: ExampleRequest = { id: 1, name: 'a' }
On
node16/nodenextresolution, import without an extension as above (a.d.tshas no runtime counterpart to point a.jsspecifier at). Onbundleror classic resolution it resolves either way.
HyperschemaTS.toDisk(schema, [dir], [options])Writes the generated declarations to disk. Mirrors Hyperschema.toDisk:
dir defaults to schema.dir (the directory the schema was loaded from).<dir>/types.d.ts.Returns the path it wrote.
The output is pure type declarations, so it's emitted as a
.d.ts. The default name avoidsindex.d.tson purpose: that would sit next to hyperschema's generatedindex.jsand TypeScript would treat it as the declarations for that module (which actually exportsresolveStruct/version, not these types).
HyperschemaTS.toCode(schema, [options])Returns the generated declarations as a string instead of writing them.
HyperschemaTS.typeName(fqn)Converts a fully-qualified hyperschema name to its generated TS type name, e.g. @example/my-struct → ExampleMyStruct.
filename — override the output path (otherwise <dir>/types.d.ts).externals — a map of fully-qualified external type name → TS expression to substitute for it (external types default to unknown).| hyperschema | TypeScript |
|---|---|
uint*, int*, float32/64, port, lexint | number |
bigint, biguint64, bigint64 | bigint |
string, utf8, ascii, hex | string |
ip, ipv4, ipv6 | string |
ipAddress, ipv4Address, ipv6Address | { host: string; family?: number; port: number } |
bool | boolean |
buffer, optionalBuffer, fixed32, fixed64, raw | Buffer |
date | Date |
none | null |
json | unknown (a json field can hold any JSON value — object, array, or scalar) |
@ns/name reference | the generated type name (e.g. NsName) |
And the modifiers:
array: true → T[]record type → Record<string, V> (records decode to a string-keyed object)enum with strings: true → a string-literal union ("a" | "b"); otherwise a numeric-literal unionalias → type X = <target>tsType)A json field carries no shape information, so it defaults to unknown. To brand a field with a concrete TS type — like Drizzle's .$type<T>() — add a tsType to the field definition. It changes only the generated type, never the encoding:
ns.register({
name: 'doc',
fields: [
{ name: 'metrics', type: 'json', tsType: 'Metrics' }, // → metrics?: Metrics
{ name: 'status', type: 'string', tsType: '"on" | "off"' }, // → status?: "on" | "off" | null
{ name: 'ids', type: 'string', array: true, tsType: 'Id' } // → ids?: Id[] | null
]
})
The override is applied as the element type, so array: true still wraps it (Id[]). You're responsible for ensuring the referenced type is in scope where the generated file is imported.
enum — narrow a string fieldFor a field that stays string on the wire but only holds a fixed set of values (a status/state field you can't model as a real hyperschema enum because of append-only versioning), add an enum annotation. tsType gives you the compile-time union; enum makes the validator enforce membership at runtime:
{ name: 'state', type: 'string', enum: ['queued', 'running', 'done'], tsType: '"queued" | "running" | "done"' }
Validation.validate(schema, fqn, { state: 'paused' }) then fails with state: must be one of "queued", "running", "done". For an array field the check applies element-wise. (A genuine hyperschema enum type is still preferable when you can use one — it also encodes as a 1-byte index and is rejected by compact-encoding itself.)
Both
tsTypeandenumare read from the live schema instance. Hyperschema does not persist unknown field keys toschema.json, so annotations only take effect when you generate types / validate in the same process that registers the schema (the usualregister→toDiskbuild script). They are lost across aHyperschema.from('./dir')reload.
Hyperschema's non-required fields don't decode to undefined — they decode to the type's default. So the generated types reflect both sides:
? (for the encode side).null (objects, strings, buffers, arrays) are also unioned with | null (for the decode side).0, 0n, false), not null.export interface Example {
id: number // required
score?: number // optional scalar → decodes to 0
blob?: Buffer | null // optional buffer → decodes to null
tags?: string[] | null // optional array → decodes to null
}
Note:
booland bitwise uint fields are always optional, because hyperschema stores them in the struct's flags bitfield.
hyperschema-ts/validation validates plain objects against a schema type before you hand them to compact-encoding.
This matters because compact-encoding is mostly silently lenient — it only throws in a couple of cases and otherwise corrupts bad input without complaint:
| input | compact-encoding |
|---|---|
ipv4("hello") | silently encodes 143.0.0.0 |
uint8(9999) | silently truncates to 15 |
hex("zz") | silently encodes 00 |
uint(-5) | throws uint must be positive |
wrong-size fixed32 | throws Incorrect buffer size |
The validator rejects everything compact-encoding would throw on or silently corrupt (out-of-range numbers, malformed IPs, wrong buffer sizes, bad enum values, missing required fields, …), with a path for each failure.
const Validation = require('hyperschema-ts/validation')
const { valid, errors } = Validation.validate(schema, '@example/request', {
id: 1,
host: 'hello' // not a valid IPv4
})
// valid === false
// errors === [{ path: '@example/request.host', value: 'hello', message: 'must be a valid IPv4 address' }]
// or throw an aggregated error:
Validation.assert(schema, '@example/request', obj)
Validation.validate(schema, fqn, value)Returns { valid, errors }, where each error is { path, value, message }. Never throws (except on an unknown fqn).
Validation.assert(schema, fqn, value)Throws an Error listing every failure if value is invalid; otherwise returns nothing.
createValidator)Every generated types.d.ts also exports a SchemaTypes registry mapping each fqn to its type. Pass it to createValidator and the validator becomes type-safe: fqn autocompletes (and typos fail to compile), and is/assert narrow value to the matching type.
import HyperschemaValidation from 'hyperschema-ts/validation'
import type { SchemaTypes } from './schema/types'
const v = HyperschemaValidation.createValidator<SchemaTypes>(schema)
if (v.is('@example/request', value)) {
value.id // value is ExampleRequest here
}
v.assert('@example/request', value) // throws, or narrows value to ExampleRequest
const { valid, errors } = v.validate('@example/request', value)
A value is T guard must return a boolean, so the three forms split by intent:
| method | returns | narrows? |
|---|---|---|
validate | { valid, errors } | no (use for error detail) |
is | value is T | yes (boolean guard) |
assert | asserts value is T | yes (throws on failure) |
createValidatorneeds a live schema at runtime — typicallyHyperschema.from('./schema')at startup. Note that a reload-from-disk schema loses field-leveltsType/enumannotations (hyperschema doesn't persist them), so annotation-basedenummembership only fires when validating against the same instance that registered the schema.
Records are validated by value; their keys are always JS strings (compact-encoding records key on
Object.keys). External and versioned types are treated as opaque (not validated structurally).
Apache-2.0