gristlabs / ts-interface-checker

Runtime library to validate data against TypeScript interfaces.
Apache License 2.0
323 stars 18 forks source link

[Feature] plugins to enhance type validation #34

Open mdesousa opened 3 years ago

mdesousa commented 3 years ago

Thanks for this awesome library! It is very well thought out.

It would be great if it could provide support to inject additional logic to validate certain types. For example, given the type below:

type Iso8601Date = string;

I would like to add logic that verifies that the value is a valid date formatted as "YYYY-MM-DD". I can see many other scenarios where similar validations would be useful. For example, for certain numbers you may want to verify that they are within a specific range (e.g. a "Percent" type would need to be between 0 and 100). Certain string types may need to be checked for length. An Email or PhoneType could be checked with a RegEx... etc.

dsagal commented 3 years ago

It's possible already! Something like this should work:

import {assert} from "chai";
import {BasicType, createCheckers} from "..";
import * as t from "..";
// tslint:disable:object-literal-key-quotes

export type Iso8601Date = string;
export interface Foo {
  date: Iso8601Date;
  event: string;
}

namespace sample {
  export const Iso8601Date = t.name("string");
  export const Foo = t.iface([], {
    "date": "Iso8601Date",
    "event": "string",
  });
  export const exportedTypeSuite: t.ITypeSuite = {
    Iso8601Date,
    Foo,
  };
}

function checkIsoDate(v: unknown): boolean {
  return typeof v === 'string' && /^\d\d\d\d-\d\d-\d\d$/.test(v) && Boolean(Date.parse(v));
}

const checkers = createCheckers({
  ...sample.exportedTypeSuite,
  Iso8601Date: new BasicType(checkIsoDate, "is not an ISO8601 date"),
});

// Type of a field is invalid.
checkers.Foo.check({event: "foo", date: '2021-01-12'});
assert.throws(() => checkers.Foo.check({event: "foo", date: 'bar'}));
assert.throws(() => checkers.Foo.check({event: "foo", date: '2020-50-50'}));

To make it generally available, you could import the object basicTypes and set basicTypes.Iso8601Date = new BasicType(checkIsoDate, "is not an ISO8601 date").

mdesousa commented 3 years ago

Excellent, thanks a lot! This works great. Just to document what I did... I separated types that need custom checking from other types into their own file. In our build process, we are not invoking ts-interface-builder for this file. We export the types and the ITypeSuite object which can be consumed by other modules similarly to suites generated by ts-interface-builder. This is the file with the types:

// types.ts
import * as t from "ts-interface-checker";

type DateIso8601 = string;

const checkIsoDate = (v: unknown): boolean =>
  typeof v === "string" &&
  /^\d\d\d\d-\d\d-\d\d$/.test(v) &&
  Boolean(Date.parse(v));

const checkers: t.ITypeSuite = {
  DateIso8601: new t.BasicType(checkIsoDate, "is not an ISO8601 date"),
};

export default checkers;
export type { DateIso8601 };

Other types with interfaces etc. that just require regular checks:

ts-interface-builder ./my-types.ts

Now to consume it:

import myTypesTI from "./my-types-ti";
import coreTI from "./types";

const checkers = createCheckers(coreTI, myTypesTI);
checkers.MyType.check(x);
mdesousa commented 3 years ago

Hi @dsagal , I have a follow up question. I'm wondering if there is a way create a checker for a more complex type that invokes other checkers. For example, for type MyObjectList below I would like to verify that all the id values are unique.

interface IMyObject {
  id: string;
  // other properties...
}

type MyObjectList = IMyObject[];

Thanks!

mdesousa commented 3 years ago

Ok... I think I figured it out. Let me know if the code below looks about right... _TI is imported from the type-ti.ts generated with the builder in order to perform base type checks.

class TMyObjectList extends t.TType {
  public getChecker(suite: t.ITypeSuite, strict: boolean): CheckerFunc {
    const ttype = _TI.TMyObjectList;
    const itemChecker = ttype.getChecker(suite, strict);
    return (value: any, ctx: IContext) => {
      const ok = itemChecker(value, ctx);
      if (!ok) return ctx.fail(null, null, 1);
      const ids = value.map((x: { id: string }) => x.id);
      const hasDuplicates = new Set(ids).size !== ids.length;
      if (hasDuplicates) return ctx.fail(null, "has duplicates", 0);
      return true;
    };
  }
}
dsagal commented 3 years ago

Yup, this looks like it should work!