@jmorecroft67/io-ts-types
TypeScript icon, indicating that this package has built-in type declarations

1.0.1 • Public • Published

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

Readme

Keywords

none

Package Sidebar

Install

npm i @jmorecroft67/io-ts-types

Weekly Downloads

1

Version

1.0.1

License

MIT

Unpacked Size

114 kB

Total Files

30

Last publish

Collaborators

  • jmorecroft67