1.10.2 • Published 4 years ago

io-interface v1.10.2

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

io-interface

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);

Motivation

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.

Limitations

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:

  1. Primitive types: number, string, boolean
  2. Other acceptable interfaces
  3. Classes
  4. Literal types (i.e. interface Address { pos: { lat: number; lng: number; } }, here Address.pos is a literal type)
  5. Union types
  6. null type
  7. Array type of 1-5

Also

  1. The fields in the interface CAN be marked as optional.
  2. any, unknown are illegal.
  3. Recursive types are NOT supported.
  4. Intersection types are NOT supported YET.
  5. Generic types supporting are experimental and for now you need to manually create factory method for it.

You need declare schemas in topological order

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.

Assign casters to classes

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>());

Builtin types

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,
);

Enum types

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');

Extending the interface

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);

Installation

Setup ts-patch

  1. npm install -D ts-patch

  2. add "postinstall" script to package.json to auto-patch the compiler after npm install

    {
      "scripts": {
        "postinstall": "ts-patch install"
      }
    }
  3. npm install -D io-interface

  4. 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:

image

Angular A DecoderService

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.

Daily usage

1. define an interface

// src/app/models/todo.ts
export interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

2. register the type to DecoderService's schemas

// src/app/services/decoder.service.ts
readonly schemas = [schema<Todo>()];

3. Use DecoderService to convert the data

// 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),
    );
  }

Optional can we DRY it more?

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.

Installation

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;
  }
}

Error handling

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:

image

1.10.2

4 years ago

1.10.1

4 years ago

1.10.0

4 years ago

1.9.1

4 years ago

1.9.0

4 years ago

1.8.1

4 years ago

1.8.0

4 years ago

1.7.4

4 years ago

1.7.3

4 years ago

1.7.2

4 years ago

1.7.1

4 years ago

1.6.2

4 years ago

1.6.1

4 years ago

1.6.0

4 years ago

1.7.0

4 years ago

1.5.34

4 years ago

1.5.33

4 years ago

1.5.32

4 years ago

1.5.31

4 years ago

1.5.26

4 years ago

1.5.25

4 years ago

1.5.24

4 years ago

1.5.23

4 years ago

1.5.22

4 years ago

1.5.21

4 years ago

1.5.20

4 years ago

1.5.19

4 years ago

1.5.18

4 years ago

1.5.17

4 years ago

1.5.16-0

4 years ago

1.5.5

4 years ago

1.5.4

4 years ago

1.5.3

4 years ago

1.5.2

4 years ago

1.5.1

4 years ago

1.5.0

4 years ago

1.5.10

4 years ago

1.5.12

4 years ago

1.5.11

4 years ago

1.5.13

4 years ago

1.5.15

4 years ago

1.5.9

4 years ago

1.5.8

4 years ago

1.5.7

4 years ago

1.5.6

4 years ago

1.4.1

4 years ago

1.4.0

4 years ago

1.3.5

4 years ago

1.3.4

4 years ago

1.4.0-0

4 years ago

1.3.3

4 years ago

1.3.2

4 years ago

1.3.1

4 years ago

1.1.1

4 years ago

1.0.2

4 years ago

1.1.0

4 years ago

1.0.1

4 years ago

1.0.0

4 years ago

1.2.3

4 years ago

1.1.4

4 years ago

1.2.2

4 years ago

1.1.3

4 years ago

1.0.4

4 years ago

1.3.0

4 years ago

1.2.1

4 years ago

1.1.2

4 years ago

1.0.3

4 years ago

0.1.4

4 years ago

0.1.5

4 years ago

0.1.1

4 years ago

0.1.3

4 years ago

0.1.0

4 years ago