Runtime types and metadata schemas for Typescript

Purpose
Typescript doesn't have runtime types, all the type information is erased at the transpile stage. And since the version 5 there is no more Reflect support neither. Here is a simple schemas definition library that lets you keep types metadata at run time, infer Typescript types from those schemas and convert raw JSON/object data to predefined structured types
Installing
npm i typizator
Documentation and tests
Nothing better than tests to show how the library works. Simply read these tests and you'll know how to use this.
Some examples
Examples with few comments is still better than just code...
Simple type conversions
Every primitive or complex schema has the .unbox
method to transform something that can be a string or some non-exact type to the strict type defined by the schema.
Primitive types used in this library are actually:
intS
representing integer number
(if the source is a floating-point type, it's rounded to an integer, if it's too big, an error is thrown)
bigintS
representing a bigint
floatS
representing a floating-point number
stringS
representing a string
dateS
representing a date
boolS
representing a boolean
By default, the type of the resulting transformation is nullable, it means that for example the type of intS.unbox(<something>)
will be number | null
. To make it strictly a number
you name to mark it as intS.notNull
.
You can combine primitive (and combined) fields to create objects' schemas using the objectS
schema factory. Let's take tests as an example:
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
expect(
simpleRecordS.unbox({
id: "12345678901234567890",
name: "Any name"
}))
.toEqual({
id: 12345678901234567890n,
name: "Any name"
})
expect(simpleRecordS.unbox({
id: 12345678901234567890n,
name: null
}))
.toEqual({
id: 12345678901234567890n,
name: null
})
expect(simpleRecordS.unbox(null))
.toEqual(null)
Some fields can be market as .optional
, then they are not required during the transformation and can be unboxed from undefined
:
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS.optional
})
expect(simpleRecordS.unbox({
id: 12345678901234567890n
}))
.toEqual({
id: 12345678901234567890n,
name: undefined
});
expect(simpleRecordS.unbox({
id: 12345678901234567890n
}))
.toEqual({
id: 12345678901234567890n
})
Extendable types
The objectS
schema has an extend
method to add new fields to the schema:
const extendedRecordS = simpleRecordS.extend({
age: intS.optional
})
This produces a schema that is compatible with the simpleRecordS
schema, but with an additional age
field.
Literal types
You can create literal types using the literalS
schema factory:
const literalType = literalS("test", "test2")
The literalS
schema will only unbox values that match one of the literals you provided:
literalType.unbox("test")
literalType.unbox("test2")
literalType.unbox("test3")
Recursive types
You can create recursive types using the recursiveS
schema factory:
test("Should allow recursive object definitions", () => {
const objectSchemaS = objectS({
value: stringS.notNull,
and: recursiveS,
or: recursiveS,
})
const unboxed = objectSchemaS.unbox({ value: "test", and: { value: "test2", or: { value: "test3" } }, or: { value: "test3" } })
expect(unboxed).toEqual({ value: "test", and: { value: "test2", or: { value: "test3" } }, or: { value: "test3" } })
})
test("Should forbid direct unboxing of recursive schemas", () => {
const recursiveSchema = recursiveS
expect(() => recursiveSchema.unbox({})).toThrow("Recursive schema cannot be unboxed directly")
})
Infer types from schemas
When you write a schema, you don't need to repeat it in the type definition, Typescript transforms it for you:
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
type SimpleRecord = InferTargetFromSchema<typeof simpleRecordS>
...then the SimpleRecord
type becomes
{
id: bigint,
name: string | null
}
Transforming JSON strings
You can unbox an entire object from a JSON string:
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
expect(
simpleRecordS.unbox(`{ "id": "12345678901234567890", "name": "Any name" }`)
)
.toEqual({
id: 12345678901234567890n,
name: "Any name"
})
expect(
() => simpleRecordS.unbox(`{ "id": null, "name": 12345678901234567890 }`)
)
.toThrow("Unboxing id, value: null: Null not allowed");
expect(
() => simpleRecordS.unbox(`{ "id": 12345678901234567890 }`)
)
.toThrow("Unboxing name, value: undefined: Field missing")
expect(
simpleRecordS.unbox(`null`)
).toBeNull()
Default values and validations
const validatedRecordS = objectS({
zeroIfNull: bigintS.byDefault(0n).optional,
errorIfNull: bigintS.byDefault(Error()).optional,
specificErrorIfNull: bigintS.byDefault(Error("NULL")).optional,
errorIfNegative: bigintS.byDefault(Error(), source => BigInt(source) < 0).optional,
stringWithPrefix: stringS.byDefault((source: any) => `prefix-${source}`, always).optional
})
expect(
validatedRecordS.unbox({ zeroIfNull: null })
).toEqual({
zeroIfNull: 0n
})
expect(
() => validatedRecordS.unbox({ errorIfNull: null })
).toThrow(Error)
expect(() => validatedRecordS.unbox({
specificErrorIfNull: null
})).toThrow("NULL")
expect(() => validatedRecordS.unbox({
errorIfNegative: -1
})).toThrow(Error)
expect(validatedRecordS.unbox({
stringWithPrefix: "str"
})).toEqual({
stringWithPrefix: "prefix-str"
})
Transforming arrays
You can transform arrays of values (primitives or complex) using the arrayS
schema:
const arrayOfStrings = arrayS(stringS).notNull
const unboxed = arrayOfStrings.unbox(["one", null, "fourty-two"])
expect(unboxed[2]).toEqual("fourty-two")
expect(unboxed[1]).toBeNull()
...and the transformation from JSON also works:
const arrayOfStrings = arrayS(stringS.notNull)
const unboxed = arrayOfStrings.unbox(`["one", "two", "fourty-two"]`)
expect(unboxed![2]).toEqual("fourty-two")
Transforming dictionaries
You can transform dictionaries mapping strings to objects using the dictionaryS
schema:
const dictionaryOfStrings = dictionaryS(stringS).notNull
const unboxed = dictionaryOfStrings.unbox({ "un": "one", "deux": null, trois: "fourty-two" })
expect(unboxed["trois"]).toEqual("fourty-two")
expect(unboxed.deux).toBeNull()
expect(unboxed.un).toEqual("one")
...and the transformation from JSON also works:
const dictionaryOfStrings = dictionaryS(stringS.notNull)
const unboxed = dictionaryOfStrings.unbox(`{"un":"one", "deux":"two", "trois":"fourty-two"}`)
expect(unboxed!.trois).toEqual("fourty-two")
Schema metadata
If you need to know at runtime the exact type of your schema's component (for serialization for example), you can use the schema's metadata:
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS,
opt: intS.optional,
dateField: dateS.byDefault(new Date("1984-01-01")),
boolField: boolS,
floatField: floatS
})
expect(simpleRecordS.metadata.dataType).toEqual("object")
const fieldsMetadata = simpleRecordS.metadata
expect(fieldsMetadata.fields.size).toEqual(6)
expect(fieldsMetadata.fields.get("id")?.metadata.dataType).toEqual("bigint")
expect(fieldsMetadata.fields.get("id")?.metadata.notNull).toBeTruthy()
expect(fieldsMetadata.fields.get("name")?.metadata.notNull).toBeFalsy()
expect(fieldsMetadata.fields.get("opt")?.metadata.optional).toBeTruthy()
expect(fieldsMetadata.fields.get("dateField")?.metadata.dataType).toEqual("date")
expect(fieldsMetadata.fields.get("boolField")?.metadata.dataType).toEqual("bool")
expect(fieldsMetadata.fields.get("floatField")?.metadata.dataType).toEqual("float")
const simpleArrayS = arrayS(simpleRecordS)
expect(simpleArrayS.metadata.dataType).toEqual("array")
expect((simpleArrayS.metadata as ArrayMetadata).elements.metadata.dataType).toEqual("object")
You can also get the schema structure data as one string by calling getSchemaSignature
. For example the following schema:
const objectToSignS = objectS({
str: stringS.notNull,
num: intS.optional,
dat: dateS,
def: bigintS.byDefault(0n),
defOpt: stringS.byDefault("0").optional,
arr: arrayS(boolS).notNull
})
will have {str:string.NN,num:int.OPT,dat:date,def:bigint.DEF,defOpt:string.OPT.DEF,arr:bool[].NN}
as a signature
API definition
Sometimes, you need to define a well-typed API that is usable both on the client and on the server side. Schemas and type transformations allow you to do that:
const cruelApi = {
world: { args: [stringS.notNull], retVal: stringS.notNull }
}
const simpleApiS = apiS({
meow: { args: [], retVal: stringS.notNull },
noMeow: { args: [] },
helloWorld: { args: [stringS.notNull, bigintS.notNull], retVal: stringS.notNull },
cruel: cruelApi
})
let isMeow = true;
const implementation = {
meow: async () => Promise.resolve("Miaou!"),
noMeow: async () => { isMeow = false; Promise.resolve(); },
helloWorld: async (name: string, id: bigint) => Promise.resolve(`Hello ${name}, your id is ${id + 1n}`),
cruel: {
world: async (val: string) => Promise.resolve(`${val}, this world is cruel`)
}
}
const caller: ApiImplementation<typeof simpleApiS> = implementation
expect(await caller.meow()).toEqual("Miaou!")
await caller.noMeow()
expect(isMeow).toBeFalsy()
expect(await caller.helloWorld("test", 12345678901234567890n))
.toEqual("Hello test, your id is 12345678901234567891")
expect(await caller.cruel.world("Oyvey")).toEqual("Oyvey, this world is cruel")
expect(simpleApiS.metadata.dataType).toEqual("api")
expect(helloWorld.dataType).toEqual("function")
expect(helloWorld.args[0].metadata.dataType).toEqual("string")
expect(helloWorld.args[1].metadata.dataType).toEqual("bigint")
expect(helloWorld.args[1].unbox("42")).toEqual(42n)
expect(helloWorld.retVal.metadata.dataType).toEqual("string")
expect(simpleApiS.metadata.implementation.meow.retVal.metadata.dataType).toEqual("string")
A little bonus: string tables as well-typed data sources
In some cases, especially when writing tests, it's prettier to represent your data as a table with fields separated by tabs or spaces.
Let's define a type schema:
const tabS = objectS({
id: bigintS,
name: stringS,
d1: intS.optional,
d2: stringS.optional,
arr: arrayS(stringS).optional
})
We can then create arrays of well-typed objects from this schema as follows:
expect(tabularInput(tabS,`
name id
"good will" 42
any 0
`)).toEqual(
[
{ name: "good will", id: 42n },
{ name: "any", id: 0n }
]
)
We often need to fill some fields with default values for each row:
expect(tabularInput(tabS, `
name id
"good will" 42
any 0
`, { d1: 1, d2: "q" }
)).toEqual(tabularInput(tabS, `
name id d1 d2
"good will" 42 1 q
any 0 1 q
`
))
Nobody prevents us from using complex types as object fields:
expect(tabularInput(tabS, `
name id arr
"good will" 42 ["a","b"]
any 0 ["c"]
`
)).toEqual(
[
{ name: "good will", id: 42n, arr: ["a", "b"] },
{ name: "any", id: 0n, arr: ["c"] }
]
)
null
and undefined
values will be transformed to corresponding types
expect(tabularInput(tabS, `
name id arr
null 42 ["a","b"]
any 0 undefined
`
)).toEqual(
[
{ name: null, id: 42n, arr: ["a", "b"] },
{ name: "any", id: 0n, arr: undefined }
]
)
...but if they are quoted, they are interpreted as strings:
expect(tabularInput(tabS, `
name id arr d2
"null" 42 ["a","b"] bulbul
any 0 [] undefined
`
)).toEqual(
[
{ name: "null", id: 42n, arr: ["a", "b"], d2: "bulbul" },
{ name: "any", id: 0n, arr: [] }
]
)
Further reading
If you want to know how I wrote this library, refer to this article.