@0x706b/io-ts-decorators v0.0.24
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 MetadataError
s, 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
4 years ago
4 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago