0.0.24 • Published 4 years ago

@0x706b/io-ts-decorators v0.0.24

Weekly downloads
-
License
MIT
Repository
github
Last release
4 years ago

io-ts-decorators

A slightly blasphemous way to interoperate functional code with other decorator libraries libraries in Typescript

Motivation

If you have ever used a library that utilizes decorators to encapsulate repeated code, you know that it is wonderful for developer sanity, as well as clean, DRY code. As someone who is striving to apply functional programming concepts, however, it can feel a bit like doublethink. In my case, suddenly my neat, succinct business models were becoming duplicated as I tried to implement validation logic that would not cause side-effects.

That's when I made io-ts-decorators: I love libraries and frameworks like type-graphql and nestjs with the "single source of truth" that they provide, but I also love functional programming. With this library, one can decorate their classes with types that will be used for validation. Branded types may also be created and used for completely custom validation logic. At the end of the day, it's like class-validators, but with @gcanti's fp-ts and io-ts under the hood.

Caveats

This library is, first and foremost, a proof of concept. There are currently no production apps making use of it, and it has not been adequately tested to the point where I would recommend it. In essence it is purely the result of my tinkering with the experimental reflect-metadata api, and my journey into functional programming techniques. Tinker with it, hack it, make it yours, caveat emptor.

Usage

Set the following options in your project's tsconfig.json:

{
   compilerOptions: {
      // ...
      experimentalDecorators: true,
      emitDecoratorMetadata: true
      // ...
   }
}

...and install and import reflect-metadata at the top of your module:

npm i reflect-metadata
import "reflect-metadata";

@ioClass and @ioProp

io-ts-decorator provides a number of useful decorators, covering nearly any type one would like to validate.

Here, we see two of them in action:

@ioClass()
class A {
   @ioProp()
   a1: number;

   @ioProp(_type => [String])
   a2: string[];
}

@ioClass()
class B {
   @ioProp(_type => A)
   b1: A;
}

Heavily inspired by type-graphql's @Field decorator, the @ioProp decorator uses the metadata reflection api to infer a primitive type, namely string, number, or boolean. Due to the limitations of this method, array-types and all other types must be explicitly defined using the first parameter of the decorator function, which is a function with the signature:

// import iots from "io-ts";

type TypeFunc = () => Constructor<any> | iots.Mixed | [ Constructor<any> | iots.Mixed ]

Thus, the _type argument from the sample above is purely for clarity, and the code could also be written as follows:

@ioProp(() => [String])
// ...
@ioProp(() => A)

For this decorator, if the TypeFunc returns an array, it must contain only one constructor function or one io-ts type, or a compile error will result.

As shown above, once a class has been decorated with @ioClass, the class constructor itself may be used as the return value in the TypeFunc parameter.

In addition, the TypeFunc return value may also be any type created by io-ts, including custom Brands:

// import iots from "io-ts";
// import isEmail from "validator/lib/isEmail";

interface EmailBrand {
   readonly Email: unique symbol;
}

const Email = iots.brand(
   iots.string,
   (a): a is iots.Branded<string, EmailBrand> => isEmail(a),
   "Email"
);
type Email = iots.TypeOf<typeof Email>;

@ioClass()
class User {
   @ioProp()
   name: string;

   @ioProp(_type => Email)
   email: string;
}

Such types will be validated according to their own decoders!

@ioProp also accepts an options object as the first or (if utilizing the first argument for a typeFunc) second argument. The options are as follows:

type PropOptions = {
   /**
    * Default: true
    * Marks whether or not a property may be omitted or null during validation.
    */
   nullable?: boolean;
   /**
    * Default: undefined
    * Used to set a custom message that will be shown in the ValidationError
    */
   message?: string;
}
@ioClass()
class A {
   @ioProp({ nullable: true })
   a1?: number;

   @ioProp(_type => [String], {
      nullable: true,
      message: "Not a valid string array"
   })
   a2?: string[];
}

@ioUnion and @ioIntersection

The next two decorators are extensions of the @ioProp decorator used for more complex types:

@ioClass()
class C {
   @ioUnion(_type => [String, Number])
   c1: String | Number;

   @ioUnion(_type => [[String], [Number]])
   c2: String[] | Number[];
}

@ioClass()
class D {
   @ioIntersection(_type => [B, C])
   d1: B & C;
}

For @ioUnion and @ioIntersection, the TypeFunc parameter has the following signature:

import iots from "io-ts";

() => Array<Constructor<any> | iots.Mixed | [Constructor<any> | iots.Mixed]>

Both @ioUnion and @ioIntersection accept the same options object, which is:

type UnionOrIntersectionOptions = PropObtions & {
   /**
    * Default: false
    * Determines if the entire type is an array
    */
   isArray?: boolean;
}

In use, that looks like:

@ioClass()
class E {
   @ioUnion(_type => [String, Number], {
      nullable: true,
      isArray: true
   })
   e1?: Array<String | Number>
}

@ioRecursion

This one is pretty cool :)

@ioClass()
class User {
   @ioProp()
   id: number;

   @ioProp()
   name: string;

   @ioProp(_type => Email)
   email: string;

   @ioRecursion()
   bestFriend?: User;

   @ioRecursion()
   friends: User[];
}

Because of the nature of this decorator, it does not accept any arguments.

More Info

The internals of each decorator perform several effectful computations to get and set metadata on decorated classes. These computations are deferred until the very end of each decorator function, where the errors are collected and stored in error metadata for each class. The only time this process will ever throw is if Reflect.defineMetadata called by the final definePropError or defineClassError function throws an error. This should never happen. But we all know how that goes.

validate

Validation with io-ts-decorators can be performed with the function validate. It has the following signature:

// import IOE from "fp-ts/lib/IOEither";

type validate = <T>(cl: Constructor<T>) => (o: T) =>
                IOE.IOEither<ValidationError, T>

Its usage can look like this:

// import E from "fp-ts/lib/Either"
// import IO from "fp-ts/lib/IO"
// import { pipe } from "fo-ts/lib/pipeable"

const executeIO = <A>(io: IO.IO<A>): A => io();

@ioClass()
class User {
   @ioProp()
   id: number;

   @ioProp()
   name: string;

   @ioProp(_type => Email)
   email: string;
}

const aUser: User = {
   id: 123,
   name: "Chad",
   email: "chad@notarealemail.com"
};

const validateUser: E.Either<ValidationError, User> = (
   obj: User
) => pipe(
   obj,
   validate(User),
   executeIO // the effectful part
);

const aUserOrError = validateUser(aUser);

E.isRight(aUserOrError) // true
   ? aUserOrError.right // { id: 123, name: "Chad", email: "chad@notarealemail.com" }
   : aUserOrError.left; // undefined

Because of the way the metadata reflection API is used in this project, an effectful computation must be performed during validation to extract the the io-ts type stored in the metadata. If that process results in an error, the error will be stored in the ValidationError on the left side of the resulting Either. If validation is successful, the right side contains the validated object with any extraneous properties removed.

ValidationError

The ValidationError class contains an array of MetadataError and a DecodeError. If there are MetadataErrors, there will be no DecodeError, as validation will not be performed, and vice-versa.

type ValidationError = {
   readonly MetadataErrors?: MetadataError[];
   readonly DecodeError?: DecodeError;
}

MetadataError contains useful information about where in the chain of events something may have gone wrong. I did my best to make the messages informative and helpful!

DecodeError is where the magic happens. It not only contains the type and unformatted io-ts errors, but also an easy-to-read tree structure inspired by the (currently experimental) 2.2.0 version of io-ts.

// import iots from "io-ts";

type DecodeError = {
   readonly type: iots.Mixed;
   readonly errors: iots.Errors;
   readonly errorTree: string;
}

errorTree looks something like this:

@ioClass()
class C {
   @ioProp()
   cProp: string;
}

@ioClass()
class B {
   @ioProp()
   bProp: number;
}

@ioClass()
class A {
   @ioIntersection(() => [ B, C ])
   aProp: B & C;
}

const o: A = {
   aProp: {
      cProp: "hello",
      bProp: "this isn't right"
   }
};

const validation = pipe(
   o,
   validate(A),
   fold(
      (e) => log(e.DecodeError.errorTree),
      log
   )
);
validation();
A
└─ Property aProp ( IntersectionType )
   └─ Property bProp ( number )
      └─ expected type number, got this isn't right
0.0.24

4 years ago

0.0.22

4 years ago

0.0.21

4 years ago

0.0.20

4 years ago

0.0.19

4 years ago

0.0.18

4 years ago

0.0.17

4 years ago

0.0.16

4 years ago

0.0.15

4 years ago

0.0.14

4 years ago

0.0.13

4 years ago

0.0.12

4 years ago

0.0.11

4 years ago

0.0.10

4 years ago

0.0.9

4 years ago

0.0.8

4 years ago

0.0.7

4 years ago

0.0.6

4 years ago

0.0.5

4 years ago

0.0.4

4 years ago

0.0.3

4 years ago

0.0.2

4 years ago

0.0.1

4 years ago