io_ts_types
An extended io-ts
Schemable
interface adding decimals, dates and ints, plus a handful of implementations for filter condition schema generation, filter function generation and protobuf serialization and proto file generation.
Before trying out this library you should be familiar with the Schemable
interfaces and usage patterns, discussed here.
Schemable
and SchemableLight
Both Schemable
and SchemableLight
interfaces add the additional functions decimal
, date
and int
, which are not included in thaae io-ts
Schemable
interfaces. The SchemableLight
interfaces are otherwise a subset of the Schemable
interfaces and apart from the primitive functions only include the functions literal
, nullable
, array
and struct
. This reduces the effort for implementations of SchemableLight
, but because the interfaces are a subset of the Schemable
interfaces schemas defined using SchemableLight
are still compatible with all Schemable
implementations too. The motivation for SchemableLight
was the schemas
and filter
implementations, which operate on schemas of database entities that do not require the additional functions in Schemable
.
schemas
The schemas
module is a SchemableLight
implementation for generating a filter condition schema from an entity schema. The generated filter condition schema is designed to be a compatible subset of a Prisma query where
clause interface for a similarly defined database entity. Further, the filter condition schema defines the condition object that is used as part of the input to filter functions generated by our filter
module. In this way we're able to generate a filter condition schema from an entity schema that we can use for querying a database and for filtering notifications of entity changes coming from a database - effectively the building blocks for a live query implementation.
Here's an example of generating a condition schema for a simple bank account entity, then using that schema to decode an input using an extended version of the io-ts
decoder. As shown, this particular input fails to decode for a number of reasons.
import {
schemable as S,
schemas as SS,
decoder as De
} from '@jmorecroft67/io-ts-types';
import * as D from 'io-ts/Decoder';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
const accountSchemas = SS.struct({
id: SS.int,
name: SS.string,
balance: SS.decimal,
updatedAt: SS.date,
createdAt: SS.date
});
const untypedCondition = {
id: {
not: {
equals: '2'
}
},
name: {
startsWith: 1
},
balance: {
gt: 'large number'
},
createdAt: {
gt: true
}
};
const { decode } = S.interpreter(De.Schemable)(accountSchemas.spec);
const condition = pipe(decode(untypedCondition), E.mapLeft(D.draw));
if (E.isLeft(condition)) {
console.log('decode failed');
console.log(condition.left);
} else {
console.log('decode succeeded');
}
decode failed
optional property "name"
└─ optional property "startsWith"
└─ cannot decode 1, should be string
optional property "balance"
└─ optional property "gt"
└─ cannot decode "large number", should be Decimal
optional property "createdAt"
└─ optional property "gt"
└─ cannot decode true, should be Date
Changing this code so the input is more palatable, as shown, and we are then able to decode.
import {
schemable as S,
schemas as SS,
decoder as De,
types
} from '@jmorecroft67/io-ts-types';
import * as D from 'io-ts/Decoder';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import Decimal from 'decimal.js';
const accountSchemas = SS.struct({
id: SS.int,
name: SS.string,
balance: SS.decimal,
updatedAt: SS.date,
createdAt: SS.date
});
const typedCondition: SS.SpecTypeOf<typeof accountSchemas> = {
id: {
not: {
equals: 2 as types.Int
}
},
name: {
startsWith: 'A'
},
balance: {
gt: new Decimal('1.23')
},
createdAt: {
gt: new Date(2022, 1, 1)
}
};
const { decode } = S.interpreter(De.Schemable)(accountSchemas.spec);
// In this case condition will be the same type as typedCondition,
// so this decode is not at required but for demonstation purposes.
const condition = pipe(decode(typedCondition), E.mapLeft(D.draw));
if (E.isLeft(condition)) {
console.log('decode failed');
console.log(condition.left);
} else {
console.log('decode succeeded');
}
decode succeeded
filter
The filter
module is a SchemableLight
implementation that acts as a function generator. It will generate a function that takes as input a filter condition object and an entity being filtered. The function returns a Left
(not filtered) or Right
(filtered), with the result in both cases being a list of reasons as to why the entity was or was not filtered. The filter condition object must adhere to the type prescribed by the Spec
type defined in the Schemas
module.
Here's an example of a condition being applied and failing to match against an entity:
import {
schemableLight as SL,
schemas as S,
filter as F,
types
} from '@jmorecroft67/io-ts-types';
import * as E from 'fp-ts/Either';
import Decimal from 'decimal.js';
import { pipe } from 'fp-ts/lib/function';
const wrap: <T>(
filter: F.Filter<T>
) => (
algType: 'failFast' | 'checkAll'
) => (spec: S.Spec<T>, obj: T) => E.Either<string[], string[]> =
(filter) => (algType) => (spec, obj) =>
pipe(
filter(algType)(spec, obj)(),
E.bimap(
(i) => i.map((j) => j()),
(i) => i.map((j) => j())
)
);
const personSchema = SL.make((S) =>
S.struct({
id: S.int,
name: S.string,
favouriteNumber: S.literal(7, 42),
savings: S.decimal,
pets: S.array(S.string),
gender: S.nullable(S.literal('male', 'female')),
died: S.nullable(S.date),
createdAt: S.date
})
);
type Person = SL.TypeOf<typeof personSchema>;
type Spec = S.Spec<Person>;
const condition: Spec = {
id: {
not: {
equals: 123 as types.Int
}
},
name: {
startsWith: 'B'
},
favouriteNumber: {
notIn: [42]
},
savings: {
gt: new Decimal(100)
},
pets: {
hasEvery: ['rex', 'bluey'],
hasSome: ['bingo', 'louie']
},
gender: {
in: ['female']
},
died: {
equals: null
},
createdAt: {
gt: new Date(Date.UTC(2000, 1, 1))
},
AND: [
{
id: {
lt: 50 as types.Int
}
},
{
id: {
gt: 0 as types.Int
}
}
],
OR: [
{
name: {
in: ['Betty', 'Beatrice']
}
},
{
name: {
in: ['Bianca', 'Barb']
}
}
],
NOT: [
{
gender: {
in: ['female']
}
},
{
savings: {
gt: new Decimal(200)
}
}
]
};
const filter = wrap(SL.interpreter(F.Schemable)(personSchema));
const alan: Person = {
id: 123 as types.Int,
name: 'Alan',
favouriteNumber: 42,
savings: new Decimal(99.9),
pets: ['rex', 'sparkle'],
gender: 'male',
died: new Date(Date.UTC(2020, 1, 1)),
createdAt: new Date(Date.UTC(1999, 1, 1))
};
const result1 = filter('checkAll')(condition, alan);
const result2 = filter('failFast')(condition, alan);
console.log('result1', result1);
console.log('result2', result2);
result1 {
_tag: 'Left',
left: [
'id: 123 = 123',
'name: Alan does not start with B',
'favouriteNumber: 42 in 42',
'savings: 99.9 !> 100',
'pets: rex,sparkle does not have at least one item in bingo,louie',
'pets: rex,sparkle does not have every item in rex,bluey',
'gender: male not in female',
'died: Sat Feb 01 2020 10:00:00 GMT+1000 (Australian Eastern Standard Time) != null',
'createdAt: Mon Feb 01 1999 10:00:00 GMT+1000 (Australian Eastern Standard Time) !> Tue Feb 01 2000 10:00:00 GMT+1000 (Australian Eastern Standard Time)',
'id: 123 !< 50',
'name: Alan not in Betty,Beatrice',
'name: Alan not in Bianca,Barb'
]
}
result2 { _tag: 'Left', left: [ 'id: 123 = 123' ] }
Here's a matching condition and the consequent output for the above program using this condition.
const condition: Spec = {
id: {
equals: 123 as types.Int
},
name: {
startsWith: 'A'
},
favouriteNumber: {
not: {
in: [7]
}
},
savings: {
gt: new Decimal(99)
},
pets: {
hasSome: ['rex', 'bingo'],
hasEvery: ['rex', 'sparkle']
},
gender: {
in: ['male']
},
died: {
equals: new Date(Date.UTC(2020, 1, 1))
},
createdAt: {
lt: new Date(Date.UTC(2000, 1, 1))
},
AND: [
{
id: {
gt: 50 as types.Int
}
},
{
id: {
lt: 200 as types.Int
}
}
],
OR: [
{
name: {
in: ['Betty', 'Beatrice']
}
},
{
name: {
in: ['Alfred', 'Alan']
}
}
],
NOT: [
{
gender: {
in: ['female']
}
},
{
savings: {
gt: new Decimal(200)
}
}
]
};
result1 {
_tag: 'Right',
right: [
'id: 123 = 123',
'name: Alan starts with A',
'favouriteNumber: 42 not in 7',
'savings: 99.9 > 99',
'pets: rex,sparkle has at least one item in rex,bingo',
'pets: rex,sparkle has every item in rex,sparkle',
'gender: male in male',
'died: Sat Feb 01 2020 10:00:00 GMT+1000 (Australian Eastern Standard Time) = Sat Feb 01 2020 10:00:00 GMT+1000 (Australian Eastern Standard Time)',
'createdAt: Mon Feb 01 1999 10:00:00 GMT+1000 (Australian Eastern Standard Time) < Tue Feb 01 2000 10:00:00 GMT+1000 (Australian Eastern Standard Time)',
'id: 123 > 50',
'id: 123 < 200',
'name: Alan in Alfred,Alan',
'gender: male not in female',
'savings: 99.9 !> 200'
]
}
result2 {
_tag: 'Right',
right: [
'id: 123 = 123',
'name: Alan starts with A',
'favouriteNumber: 42 not in 7',
'savings: 99.9 > 99',
'pets: rex,sparkle has at least one item in rex,bingo',
'pets: rex,sparkle has every item in rex,sparkle',
'gender: male in male',
'died: Sat Feb 01 2020 10:00:00 GMT+1000 (Australian Eastern Standard Time) = Sat Feb 01 2020 10:00:00 GMT+1000 (Australian Eastern Standard Time)',
'createdAt: Mon Feb 01 1999 10:00:00 GMT+1000 (Australian Eastern Standard Time) < Tue Feb 01 2000 10:00:00 GMT+1000 (Australian Eastern Standard Time)',
'id: 123 > 50',
'id: 123 < 200',
'name: Alan in Alfred,Alan',
'gender: male not in female'
]
}
protobuf-codec
The protobuf-codec
module is a Schemable
implementation that provides functions for encoding and decoding between JS objects and the protobuf binary format, plus a function for generating a protobufjs.Type
object that may itself be used to generate a protobuf file. This provides a convenient mechanism for using protobuf serialisation and deserialisation just by defining a regular schema in code, without bothering with field numbers and without having to define protobuf files. Because we are implementing our extended Schemable
interface, it also handles the types we use for date
, int
and decimal
.
Here's an example of our protobuf-codec
module in action:
import 'jest-extended';
import { protobufCodec as SPC } from '@jmorecroft67/io-ts-types';
import * as protobufjs from 'protobufjs';
import { pipe } from 'fp-ts/lib/function';
import Decimal from 'decimal.js';
const codecMaker = pipe(
SPC.struct({
myNum: SPC.number,
myString: SPC.string,
myBool: SPC.boolean,
myDate: SPC.date,
myLiteral: SPC.literal('hello', 'world')
}),
SPC.intersect(
SPC.partial({
myDecimal: SPC.decimal
})
)
);
const pbType = new protobufjs.Type('MyMessage');
pbType.add(new protobufjs.Field('myNum', 1, 'double'));
pbType.add(new protobufjs.Field('myString', 2, 'string'));
pbType.add(new protobufjs.Field('myBool', 3, 'bool'));
pbType.add(new protobufjs.Field('myDate', 4, 'int64'));
pbType.add(new protobufjs.Field('myLiteral', 5, 'int32'));
pbType.add(new protobufjs.Field('myDecimal', 6, 'string'));
const codec = codecMaker(SPC.makeContext());
// object encoded by us, decoded by protobufjs
// note in the output the encoding of our special types
// - literal uses the index of the literal in our literal definition array,
// so 0 in this case
// - date uses the epoch value as an int64
// - decimal uses the string representation
const buf = codec
.encodeDelimited({
myBool: true,
myDate: new Date(2000, 1, 1),
myLiteral: 'hello',
myNum: 123,
myString: 'xxxx',
myDecimal: new Decimal('123.45')
})
.finish();
const obj1 = pbType.decodeDelimited(buf);
console.log('obj1', obj1);
// object encoded by us, decoded by us
const buf2 = codec
.encodeDelimited({
myBool: true,
myDate: new Date(2000, 1, 1),
myLiteral: 'hello',
myNum: 123,
myString: 'xxxx',
myDecimal: new Decimal('123.45')
})
.finish();
const obj2 = codec.decodeDelimited(buf2);
console.log('obj2', obj2);
// object encoded by protobufjs, decoded by us
const buf3 = pbType
.encodeDelimited({
myBool: true,
myDate: 0,
myLiteral: 'hello',
myNum: 123,
myString: 'xxxx',
myDecimal: '123.45'
})
.finish();
const obj3 = codec.decodeDelimited(buf3);
console.log('obj3', obj3);
obj1 MyMessage {
myNum: 123,
myString: 'xxxx',
myBool: true,
myDate: Long { low: 139427584, high: 221, unsigned: false },
myLiteral: 0,
myDecimal: '123.45'
}
obj2 {
_tag: 'Right',
right: {
myNum: 123,
myString: 'xxxx',
myBool: true,
myDate: 2000-01-31T14:00:00.000Z,
myLiteral: 'hello',
myDecimal: 123.45
}
}
obj3 {
_tag: 'Right',
right: {
myNum: 123,
myString: 'xxxx',
myBool: true,
myDate: 1970-01-01T00:00:00.000Z,
myLiteral: 'hello',
myDecimal: 123.45
}
}
Here's an example of how we deal with errors, which we handle similarly to the regular decoder provided in io-ts
:
import { protobufCodec as SPC } from '@jmorecroft67/io-ts-types';
import * as protobufjs from 'protobufjs';
import { pipe } from 'fp-ts/lib/function';
import * as E from 'fp-ts/Either';
import * as D from 'io-ts/Decoder';
const codecMaker = pipe(
SPC.struct({
myNum: SPC.number,
myString: SPC.string,
myBool: SPC.boolean,
myDate: SPC.date,
myLiteral: SPC.literal('hello', 'world')
}),
SPC.intersect(
SPC.partial({
myDecimal: SPC.decimal
})
)
);
const pbType = new protobufjs.Type('MyMessage');
pbType.add(new protobufjs.Field('myNum', 1, 'double'));
pbType.add(new protobufjs.Field('myString', 2, 'string'));
pbType.add(new protobufjs.Field('myBool', 3, 'bool'));
pbType.add(new protobufjs.Field('myDate', 4, 'int64'));
pbType.add(new protobufjs.Field('myLiteral', 5, 'int32'));
pbType.add(new protobufjs.Field('myDecimal', 6, 'string'));
const codec = codecMaker(SPC.makeContext());
// object encoded by protobufjs, decoded by us
const buf = pbType
.encodeDelimited({
myBool: true,
myDate: 0,
myDecimal: '123.45'
})
.finish();
const obj = codec.decodeDelimited(buf);
if (E.isLeft(obj)) {
console.log('obj', D.draw(obj.left));
}
obj required property "myNum"
└─ cannot decode undefined, should be defined
required property "myString"
└─ cannot decode undefined, should be defined
required property "myLiteral"
└─ cannot decode undefined, should be defined