antireflection v1.0.0
Antireflection for TypeScript: create your types from your metadata
TypeScript version 2.2.1 or later is required
npm install antireflection
Antireflection allows you to have single source of definition for your application types, and use it for both compile-time and run-time type checking. TypeScript does not provide any support for accessing types at run-time, so you have to provide the data for describing types at run time, and rely on the ability of TypeScript compiler to infer types at compile time:
import * as ar from 'antireflection';
const pointType = ar.object({ // this is ordinary object initializer
x: ar.number, // ar.number (and ar.string) are just objects defined in antireflection.ts
y: ar.number
});
type Point = ar.Type<typeof pointType>;
// it's the same as type Point = {x: number, y: number};
// but now you have its description at runtime:
// pointType.p() returns an object with Point type properties
// so, for example, you can check the type at run time:
const errors = ar.check(pointType, {x: 'a'});
console.dir(errors);
// x: expected number, got string
// y: expected number, got undefined
These primitive value types are supported:
ar.string
ar.number
ar.boolean
ar.date
nested objects are supported
const circleType = ar.object({
center: pointType,
radius: ar.number
});
type Circle = ar.Type<typeof circleType>;
// same as type Circle = {center: Point, radius: number};
Circular structures are not supported - TypeScript infers any
type for an object that is
"referenced directly or indirectly in its own initializer", which does not allow to derive a type from its value.
arrays are supported
const polygonType = ar.object({
points: ar.array(pointType)
});
type Polygon = ar.Type<typeof polygonType>;
// the same as type Polygon = {points: Point[]};
optional properties are supported, sort of
const labeledPointType = ar.object({
...pointType.p(), // use object spread to reuse properties defined for other types
label: ar.optional(ar.string)
});
type LabeledPoint = ar.Type<typeof labeledPointType>;
// same as type LabeledPoint = Point & {label: string | undefined};
However, optional properties must be present in direct object initialization,
all you can do is to assign undefined
to them. The workaround is to use
ar.create()
function which accepts two arguments: type descriptor and initial value,
which can have optional properties omitted:
const p = ar.create(labeledPointType, {x: 0, y: 0}); // ok
// const p1: LabeledPoint = {x: 0, y: 0}; // does not compile: Property 'label' is missing
const p2: LabeledPoint = {x: 0, y: 0, label: undefined}; // also ok
checked conversion to/from JSON
antireflection-json
provides two functions toJSON()
and fromJSON()
that rely on antireflection type descriptions for doing conversion.
toJSON()
will output only properties that exist in type descriptors:
import * as ar from 'antireflection';
import * as arj from 'antireflection-json';
const messageType = ar.object({
text: ar.string,
createdTime: ar.date
});
type Message = ar.Type<typeof messageType>;
const m = {
text: 'abc',
createdTime: new Date(Date.UTC(2017, 1, 1, 2, 3, 4, 0)),
extra: 'stuff'
};
const json = arj.toJSON(messageType, m);
console.dir(json);
//{ text: 'abc', createdTime: '2017-02-01T02:03:04.000Z' }
fromJSON()
will throw an exception if the value does not conform to the type:
json.createdTime = 'e';
try {
const r = arj.fromJSON(messageType, json);
console.dir(r);
} catch(e) {
console.log(e.message);
// createdTime: invalid date: e
}
data validation
antireflection-validate
adds two optional properties to type descriptors: validate
and constraint
.
validate
is a function that takes a value and returns an array of error messages, or empty array if the value is valid.constraint
is validate.js constraint, the value is validated by calling validate.single().
It can be used like this:
import * as ar from 'antireflection';
import * as arv from 'antireflection-validate';
const pointType = ar.object({x: ar.number, y: ar.number});
const circleType = ar.object({
center: pointType,
radius: {...ar.number, validate: (v: number) =>
v < 0 ? [`invalid value: ${v}. must be non-negative`] : []
}
});
console.dir(arv.validate(circleType, {center: {x: 0, y: 0}, radius: -1}));
// ['radius: invalid value: -1. must be non-negative']
const messageType = ar.object({
text: {...ar.string, constraint: {presence: true}},
to: {...ar.string, constraint: {email: true}}
});
type Message = ar.Type<typeof messageType>;
const messages: Message[] = [
{text: 'Hi!', to: 'foo@bar.com'},
{text: '', to: 'admin@example.com'},
{text: 'letter', to: 'someone@special'}
];
console.dir(arv.validate(ar.array(messageType), messages));
// [
// '[1].text: can\'t be blank',
// '[2].to: is not a valid email'
// ]
antireflection internals: types for properties and type descriptors
In antireflection.ts
, ar.object
is defined as generic function
export function object<P extends Properties>(p: P)
The type of its parameter is defined as mapped type that has specific object properties mapped to their type descriptors.
It extends Properties
type
export type Properties = {
[N in string]: TypeDescriptor;
};
where TypeDescriptor is the supertype for all representable types.
ar.number
, ar.string
, ar.boolean
, ar.date
, ar.array
, ar.object
and ar.optional
all return values typed as appropriate instances of some type that extends TypeDescriptor
.
The return type for ar.object
is generic interface named OD
, short for 'object descriptor': interface OD<P extends Properties>
.
When you define object type
const pointType = ar.object({x: ar.number, y: ar.string});
its type descriptor, pointType
, has a method named p()
(short for properties) that returns appropriate instance of Properties
type describing properties of that object.
extensions: adding your own value types
To add your own value type and make it available for declaring object properties, you have to do 3 things:
- extend
TypeMap
interface which is used to declare types for properties when you useType<>
- extend
TypeDescriptorMap
interface which is used to list all possibleTypeDescriptor
types - define constant
TypeDescriptor
value withcheck
,clone
,toJSON
andfromJSON
method implementations for your type
Interfaces are extended using declaration merge feature of TypeScript.
You have to add ambient declaration for antireflection
module in order to extend it. For example, here is a module that adds moment value type
file antireflection-moment.ts:
import * as ar from 'antireflection';
import * as arj from 'antireflection-json';
import * as mm from 'moment';
declare module 'antireflection' {
export interface TypeMap<D extends PD> {
moment: mm.Moment;
}
export interface TypeDescriptorMap {
moment: T<'moment'>;
}
}
export const moment: ar.T<'moment'> = {
t: 'moment', // must be the same as key that you add to TypeMap and TypeDescriptorMap
check: (v: ar.Value, path: ar.Path) =>
mm.isMoment(v) ? undefined
: `${ar.pathMessage(path)}expected moment, got ${ar.typeofName(v)}`
,
clone: (v: mm.Moment) => mm(v),
toJSON: ({v}) => v.toJSON(),
fromJSON: ({v}) => mm(new Date(v))
};
It can be used like this:
import * as ar from 'antireflection';
import * as arm from './antireflection-moment';
import * as moment from 'moment';
const messageType = ar.object({
text: ar.string,
createdTime: arm.moment
});
type MessageType = ar.Type<typeof messageType>;
const m = ar.create(messageType, {text: 't', createdTime: moment(new Date)});
console.log(moment.isMoment(m.createdTime)); // true
//const m2: MessageType = {text: 't', createdTime: new Date()};
// Type '{ text: string; createdTime: Date; }' is not assignable
// to type 'O<{ text: T<"string">; createdTime: T<"moment">; }>'.
// Types of property 'createdTime' are incompatible.
// Type 'Date' is not assignable to type 'Moment'.
// Property 'format' is missing in type 'Date'.
const errors = ar.check(messageType, {text: 't', createdTime: new Date()});
console.dir(errors); // [ 'createdTime: expected moment, got object' ]
antireflection internals: structural recursion
There are three functions exported: ar.mapSource
, ar.mapTarget
and ar.Reduce
that take a function, a type descriptor and a value;
and apply the function to all properties, sub-properties and sub-elements of a value as described by type descriptor.
function mapSource<D extends TypeDescriptor>(f: SourceMapper, v: Type<D>, d: D, path: Path = []): Value {
mapSource
throws if value v
does not conform to type descriptor d
, and returns resulting value as generated by mapping function f
.
function mapTarget<D extends TypeDescriptor>(f: TargetMapper, v: Value, d: D, path: Path = []): Type<D> {
mapTarget
takes any value v, applies mapping function f
to it and throws if resulting value does not conform to type descriptor d
.
function reduce<R>(f: Reducer<R>, v: Value, r: R, d: TypeDescriptor, path: Path = []): R {
reduce
throws if value v
does not conform to type descriptor d
, and applies reducing function f
according to the structure described by d
.
As an example, here is implementation of ar.typedClone
and ar.create
:
export function typedClone<D extends TypeDescriptor>(d: D, v: Type<D>): Type<D> {
return mapSource(({v}) => v, v, d);
}
export type PartialObject<P extends Properties> = {[N in keyof P]?: Type<P[N]>};
// NOTE: throws if anything non-optional in P is absent in o
export function create<P extends Properties>(d: OD<P>, o: PartialObject<P>): O<P> {
return mapTarget(({v}) => v, o, d);
}
extending type descriptors
All type descriptors inherit from generic interface which is named T
. You can declare additional properties
for type descriptors and have them type-checked by adding ambient declaration for antireflection
module
and using declaration merging for T
interface.
antireflection-default
For example, antireflection-default adds type-checked optional default value to object properties, defined like this:
import * as ar from 'antireflection';
declare module 'antireflection' {
export interface T<N extends keyof TypeMap<PD>> extends Partial<CompositeObjectDescriptor> {
defaultValue?: (TypeMap<TypeDescriptor>[N]) | (() => TypeMap<TypeDescriptor>[N]);
}
// defaultValue can be either a value or a function that provides the value.
}
It also defines its own version of create()
that takes into account default values:
export function create<P extends ar.Properties>(d: ar.OD<P>, o: ar.PartialObject<P>) {
return ar.mapTarget(f, o, d);
function f(args: ar.TargetMapperArgs): ar.Value {
return args.v !== undefined ? args.v
: typeof args.d.defaultValue === 'function' ? args.d.defaultValue()
: args.d.defaultValue
;
}
}
It can be used like this:
import * as ar from 'antireflection';
import * as ard from 'antireflection-default';
const messageType = ar.object({
text: {...ar.string, defaultValue: ''},
createdTime: {...ar.date, defaultValue: () => new Date()}
});
type Message = ar.Type<typeof messageType>;
const m = ard.create(messageType, {});
// will throw at runtime for non-optional properties with no value and no default
console.dir(m);
// { text: '', createdTime: 2017-02-27T17:21:04.521Z }
Another example for adding custom type descriptor properties is antireflection-validate extension.
7 years ago