typizator
Version:
Runtime types and metadata schemas for Typescript
465 lines (384 loc) • 14.5 kB
Markdown
# Runtime types and metadata schemas for Typescript
 [](https://badge.fury.io/js/typizator) [](https://nodejs.org/)
## 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
```Bash
npm i typizator
```
## Documentation and tests
Nothing better than tests to show how the library works. Simply read [these tests](https://github.com/cvdsfif/typizator/blob/main/tests/index.test.ts) 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:
```ts
// We define the type schema
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
// In the source type the primitives can be transformed to match the types of the schema:
expect(
simpleRecordS.unbox({
id: "12345678901234567890",
name: "Any name"
}))
.toEqual({
id: 12345678901234567890n,
name: "Any name"
})
// Because you market the id field as .not null, the compiler will display an error if you try to make it null
// The name field is not marked, so it's perfectly fine
expect(simpleRecordS.unbox({
id: 12345678901234567890n,
name: null
}))
.toEqual({
id: 12345678901234567890n,
name: null
})
// The whole simpleRecordS is not marked as .notNull neither, so it can be transformed from null too
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`:
```ts
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:
```ts
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:
```ts
const literalType = literalS("test", "test2")
```
The `literalS` schema will only unbox values that match one of the literals you provided:
```ts
literalType.unbox("test") // OK
literalType.unbox("test2") // OK
literalType.unbox("test3") // Error
```
### Recursive types
You can create recursive types using the `recursiveS` schema factory:
```ts
test("Should allow recursive object definitions", () => {
// GIVEN an object schema
const objectSchemaS = objectS({
value: stringS.notNull,
and: recursiveS,
or: recursiveS,
})
// WHEN unboxing the object schema
const unboxed = objectSchemaS.unbox({ value: "test", and: { value: "test2", or: { value: "test3" } }, or: { value: "test3" } })
// THEN the unboxed object is correct
expect(unboxed).toEqual({ value: "test", and: { value: "test2", or: { value: "test3" } }, or: { value: "test3" } })
})
test("Should forbid direct unboxing of recursive schemas", () => {
// GIVEN a recursive schema
const recursiveSchema = recursiveS
// WHEN unboxing the recursive schema
// THEN an error is thrown
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:
```ts
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
type SimpleRecord = InferTargetFromSchema<typeof simpleRecordS>
```
...then the `SimpleRecord` type becomes
```ts
{
id: bigint,
name: string | null
}
```
### Transforming JSON strings
You can unbox an entire object from a JSON string:
```ts
const simpleRecordS = objectS({
id: bigintS.notNull,
name: stringS
})
// The source loosely-typed JSON is correctly transformed to a well-typed object
expect(
simpleRecordS.unbox(`{ "id": "12345678901234567890", "name": "Any name" }`)
)
.toEqual({
id: 12345678901234567890n,
name: "Any name"
})
// If you try to pass an unexpected null value, the `unbox` method throws an exception
expect(
() => simpleRecordS.unbox(`{ "id": null, "name": 12345678901234567890 }`)
)
.toThrow("Unboxing id, value: null: Null not allowed");
// An exception is also thrown if a non-`.optional` field is missing
expect(
() => simpleRecordS.unbox(`{ "id": 12345678901234567890 }`)
)
.toThrow("Unboxing name, value: undefined: Field missing")
// ...but nulls are perfectly accepted if the schema allows them
expect(
simpleRecordS.unbox(`null`)
).toBeNull()
```
### Default values and validations
```ts
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
})
// Null is replaced by zero if required
expect(
validatedRecordS.unbox({ zeroIfNull: null })
).toEqual({
zeroIfNull: 0n
})
// If needed, a null value can throw an exception on unboxing
expect(
() => validatedRecordS.unbox({ errorIfNull: null })
).toThrow(Error)
// You can define a specific error if needed
expect(() => validatedRecordS.unbox({
specificErrorIfNull: null
})).toThrow("NULL")
// You can make more complex validity checks
expect(() => validatedRecordS.unbox({
errorIfNegative: -1
})).toThrow(Error)
// You can use defaults as value transformers
expect(validatedRecordS.unbox({
stringWithPrefix: "str"
})).toEqual({
stringWithPrefix: "prefix-str"
})
```
### Transforming arrays
You can transform arrays of values (primitives or complex) using the `arrayS` schema:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
// The API can be organised in "folders" to build hierarchies
const cruelApi = {
world: { args: [stringS.notNull], retVal: stringS.notNull }
}
// You can then add a sub-apis to your API
const simpleApiS = apiS({
meow: { args: [], retVal: stringS.notNull },
noMeow: { args: [] },
helloWorld: { args: [stringS.notNull, bigintS.notNull], retVal: stringS.notNull },
cruel: cruelApi
})
// The implementation is well-typed and doesn't let you violate your type schema
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:
```ts
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:
```ts
// If a string contains spaces, it has to be quoted
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:
```ts
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:
```ts
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
```ts
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:
```ts
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](https://medium.com/@cvds.eu/runtime-types-serialization-and-validation-the-magic-of-typescript-type-model-869579ba1bbf).