io-interface
auto generates runtime validation solutions from TypeScript native interfaces. It's main purpose is to validate JSON data from a web API but you can use it to validate any external data.
// src/app/models/user.ts
// Step 1. define an interface
export interface User {
name: string;
title?: string;
age: number;
recentOrders: Order[];
}
// src/app/services/decoder.service.ts
// Step 2. add `schema<User>()` to the manifest to register
import { schema, Decoder } from 'io-interface';
export const decoder = new Decoder([schema<User>()]);
// src/app/users/user.service.ts
// Step 3. use `decode()` and `decodeArray()` to do the validation/conversion.
const user: User | undefined = decoder.decode<User>('User', json, console.error);
const users: User[] | undefined = decoder.decodeArray<User>('User', json, console.error);
Validating data coming from an external system is always good. Image you found a powerful runtime validation library io-ts and want to adopt it to your project, but the concern is all the team members have to learn this new library and understand how it works. This would slow down the developing pace. And in many cases we don't want this slowdown.
So here comes the encapsulation. The goal is the rest of the team need to learn nearly nothing to use this facility and the minimum code changes are required to adopt it. For other developers they can still simply use a native TypeScript interface to represent the data model from web API. And use one-liner to auto-generate the validation solution.
You can check out this Angular repo as a demo project.
Since its main purpose is for JSON validation, only a subset of interface syntax is supported here. The fields of an interface must of type:
- Primitive types:
number
,string
,boolean
- Other acceptable interfaces
- Classes
- Literal types (i.e.
interface Address { pos: { lat: number; lng: number; } }
, hereAddress.pos
is a literal type) - Union types
null
type- Array type of 1-5
Also
- The fields in the interface CAN be marked as optional.
any
,unknown
are illegal.- Recursive types are NOT supported.
- Intersection types are NOT supported YET.
- Generic types supporting are experimental and for now you need to manually create factory method for it.
Right now there's no dependency resolver implemented. So for example you have these interfaces:
interface LatLng {
lat: number;
lng: number;
}
interface Order {
price: number;
pos: LatLng;
}
You must declare LatLng
's schema before Order
:
const schemas = [
//...
schema<LatLng>(), // MUST come first
schema<Order>(),
];
But don't worry too much about this, if you declare them in a wrong order, you will receive a error from the library.
It's very often we're passing Date
in JSON, and Date
is a class instead of an interface in TypeScript.
interface Order {
date: Date;
}
We have to manually create a caster for a class. Luckily the decoder for Date
is already implemented in io-ts-types. What we need to do is to just incluce into the 2nd argument.
import { DateFromISOString } from 'io-ts-types/lib/DateFromISOString';
const decoder = new Decoder([schema<Order>()], {
Date: DateFromISOString,
});
It's equivalent to:
const decoder = new Decoder();
decoder.casters.Date = DateFromISOString;
decoder.register(schema<Order>());
In types.ts you can found some common types and its casters:
/** @since 1.7.3 */
export const casters = {
Date: DateFromISOString,
Int: t.Int,
Latitude: Latitude,
Longitude: Longitude,
NonEmptyString: NonEmptyString,
};
Usage:
import { casters } from 'io-interface/types';
const dec = new Decoder(
[
/* schemas */
],
casters,
);
You can easily register an enum using enumSchema
import { enumSchema } from 'io-interface';
enum Status {
Pending = 'pending',
Complete = 'complete',
}
decoder.register(enumSchema('Status', Status));
const status = decoder.decode<Status>('Status', 'pending');
You may subclass Model<T>
to extend the interface to a class:
import { Model } from 'io-interface';
interface IUser {
firstName: string;
lastName: string;
}
interface User extends IUser {}
class User extends Model<IUser> { // IMPORTANT!!! You need Model<T> to get the constructor
get name(): string {
return `${user.firstName} ${user.lastName}`;
},
}
decoder.register({
schema: schema<IUser>(),
constructor: User,
className: 'User',
});
const user = decoder.decode<User>('User', { firstName: 'Yang', lastName: 'Liu' });
console.log(user.name);
-
npm install -D ts-patch
-
add "postinstall" script to
package.json
to auto-patch the compiler afternpm install
{ "scripts": { "postinstall": "ts-patch install" } }
-
npm install -D io-interface
-
add transformer to
tsconfig.json
{ "compilerOptions": { "plugins": [{ "transform": "io-interface/transform-interface" }] } }
To verify the setup, try compile this file.
import { schema } from 'io-interface';
interface Order {
price: number;
date: Date;
note?: string;
pricelines: number[];
}
const OrderSchema = schema<Order>();
console.log(OrderSchema);
You should see the console message like this:
The example code is as follows.
import { Injectable } from '@angular/core';
import { Decoder, schema } from 'io-interface';
import { casters } from 'io-interface/types';
import { BadTodo } from '../models/bad-todo';
import { Todo } from '../models/todo';
@Injectable({
providedIn: 'root',
})
export class DecoderService {
readonly schemas = [schema<Todo>(), schema<BadTodo>()];
readonly dec = new Decoder(this.schemas, casters);
decode<T>(typeName: string, data: unknown): T | undefined {
return this.dec.decode<T>(typeName, data, console.error);
}
decodeArray<T>(typeName: string, data: unknown): T[] | undefined {
return this.dec.decodeArray<T>(typeName, data, console.error);
}
}
Just replace console.error
with a real error handler in your project.
// src/app/models/todo.ts
export interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
// src/app/services/decoder.service.ts
readonly schemas = [schema<Todo>()];
// src/app/services/todo.service.ts
getTodo(): Observable<Todo> {
return this.http.get('https://jsonplaceholder.typicode.com/todos/1').pipe(
map(json => this.dec.decode<Todo>('Todo', json)),
filter(todo => !!todo),
);
}
As you can see from the signature decode<Todo>('Todo', json)
, Todo
repeats twice. But for native TypeScript this is needed because the type parameter is for static environment and method parameter is for runtime environment. I don't find a very good solution here but I created a specific TypeScript transformer to expand a macro such as decode<Todo>(json)
to decode<Todo>('Todo', json)
. Since TypeScript will never populate the interface information to runtime so I guess this would be the easiest way to reduce the duplication.
Because I didn't find any decent macro system for TypeScript so this macro implementation is very specific and not configurable. It replaces:
requestAndCast<User>(...args);
To:
request(...args, (decoder, data, onError) => decoder.decode('User', data, onError));
So if you want use this ensure you declares such methods.
To enable this, install transform-request
to tsconfig plugins:
{
"compilerOptions": {
"plugins": [
{ "transform": "io-interface/transform-interface" },
{ "transform": "io-interface/transform-request" } // <--- add this
]
}
}
And here's an example implementation.
type DecoderCallback<T> = (
c: Decoder,
data: unknown,
onError: (e: string[]) => void,
) => T | undefined;
class ApiService {
// use it in your codebase
async requestAndCast<T>(options: ApiOptions): T {
throw new Error(`macro failed to expand,
check your tsconfig and ensure "io-interface/transform-request" is enabled`);
}
// do not call it directly, transformer will call it
async request<T>(
options: ApiOptions,
cb: (c: Decoder, data: unknown, e?: DecoderCallback<T>) => T | undefined,
) {
const data: Object = await fetch(options);
const casted: T = cb(c, data, console.error);
return casted;
}
}
If encoding failed, decode()
or decodeArray()
will can an onError callback with signature: string[] => void
where the argument is an array of error messages. Here's the screenshot of such error messages: