ts-type-check
Version:
Check json value based of Typescript type in string
752 lines (751 loc) • 22.1 kB
JavaScript
"use strict";
// import * as util from 'util';
Object.defineProperty(exports, "__esModule", { value: true });
exports.checkTsType = exports.parseTsType = exports.TypeCheckError = void 0;
const dev = (...args) => {
return;
console.log('[dev]', ...args);
};
const devStr = (name, str) => {
return;
dev(name);
dev('='.repeat(16));
console.log(str);
dev('='.repeat(16));
};
class TypeCheckError extends TypeError {
constructor(message) {
super(message);
this.statusCode = 400;
this.status = 400;
}
}
exports.TypeCheckError = TypeCheckError;
class TypeChecker {
compile() {
return this;
}
}
class OrObjectTypeChecker extends TypeChecker {
constructor(left, right) {
super();
this.left = left;
this.right = right;
}
get type() {
return `${this.left.type} | ${this.right.type}`;
}
check(data, options) {
return this.compile().check(data, options);
}
compile() {
return new OrTypeChecker(this.left, this.right);
}
}
class ObjectTypeChecker extends TypeChecker {
constructor(fields) {
super();
this.fields = fields;
}
get type() {
let acc = '{ ';
acc += this.fields
.map(({ name, optional, type }) => {
if (name.split('').some(c => !isWordChar(c))) {
name = JSON.stringify(name);
}
return `${name}${optional ? '?' : ''}: ${type.type}`;
})
.join(', ');
acc += ' }';
return acc;
}
check(data, options) {
if (typeof data !== 'object') {
throw new TypeCheckError('expect object, got: ' + typeof data);
}
for (const field of this.fields) {
if (!field.optional && !(field.name in data)) {
throw new TypeCheckError(`expect field '${field.name}' but missing`);
}
if (field.name in data) {
field.type.check(data[field.name], options);
}
}
for (const key of Object.keys(data)) {
if (!this.fields.find(x => x.name === key)) {
// dev('extra field:', util.inspect({ type: this.type, data }));
throw new TypeCheckError(`got extra field '${key}'`);
}
}
}
and(that) {
// console.log(`merge and:`, util.inspect({ this: this, that }, { depth: 99 }));
const fields = new Map();
this.fields.forEach(field => fields.set(field.name, field));
that.fields.forEach(field => {
if (!fields.has(field.name)) {
fields.set(field.name, field);
return;
}
const thisField = fields.get(field.name);
const thatField = field;
const newField = {
name: thisField.name,
optional: thisField.optional && thatField.optional,
// type: new AndTypeChecker(thisField.type, thatField.type).compile(),
type: new AndTypeChecker(thisField.type, thatField.type),
};
fields.set(newField.name, newField);
});
return new ObjectTypeChecker(Array.from(fields.values()));
}
/**@deprecated*/
or(that) {
return new OrObjectTypeChecker(this, that);
}
}
function isWordChar(c) {
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c === '_';
}
let parseWordRegex = /^[a-zA-Z_]\w*/;
function parseWord(s) {
let match = s.match(parseWordRegex);
if (!match) {
throw new TypeCheckError(`expect word, got: '${s[0]}'`);
}
const word = match[0];
return {
res: s.substring(word.length),
data: word,
};
}
function expectChar(s, c) {
if (s[0] !== c) {
throw new TypeCheckError(`expect '${c}', got '${s[0]}'`);
}
}
function parseObjectType(s) {
if (s.length === 0) {
throw new TypeCheckError('empty type');
}
if (s[0] !== '{') {
throw new TypeCheckError(`expect '{' got '${s[0]}'`);
}
s = s.substring(1);
const fields = [];
for (;;) {
s = s.trim();
const c = s[0];
if (c === '}') {
s = s.substring(1);
return {
res: s,
data: new ObjectTypeChecker(fields),
};
}
let fieldName;
{
const res = parseObjectKey(s);
fieldName = res.data;
s = res.res.trim();
}
let fieldOptional = false;
if (s[0] === '?') {
fieldOptional = true;
s = s.substring(1).trim();
}
expectChar(s, ':');
s = s.substring(1).trim();
let fieldType;
{
devStr('parseType for field ' + fieldName, s);
const res = parseType(s);
fieldType = res.data;
s = res.res.trim();
}
switch (s[0]) {
case ',':
case ';':
s = s.substring(1).trim();
}
fields.push({
name: fieldName,
optional: fieldOptional,
type: fieldType,
});
}
}
function parseStringValue(s) {
if (s.length === 0) {
throw new TypeCheckError('empty type');
}
if (s.length < 1) {
throw new TypeCheckError('expect string, but only got length of ' + s.length);
}
const q = s[0];
let acc = '';
for (let i = 1; i < s.length; i++) {
const c = s[i];
switch (c) {
case '\\':
i++;
acc += s[i];
break;
case q:
return {
res: s.substring(i + 1),
data: acc,
};
default:
acc += c;
}
}
throw new Error(`expect string, but missing closing quote: ${q}`);
}
function parseStringType(s) {
const res = parseStringValue(s);
return {
res: res.res,
data: new LiteralChecker(res.data),
};
}
function isDigit(c) {
return '0' <= c && c <= '9';
}
class LiteralChecker extends TypeChecker {
constructor(value) {
super();
this.value = value;
this.type = JSON.stringify(this.value);
}
check(data, options) {
if (data !== this.value) {
throw new TypeCheckError(`expect ${this.type}, got: ${JSON.stringify(data)}`);
}
}
}
function parseIntStr(s) {
s = s.trim();
if (s.length === 0) {
throw new TypeCheckError('empty type string, expect integer');
}
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (!isDigit(c)) {
if (i === 0) {
throw new TypeCheckError(`expect integer, got: ${c}`);
}
return {
res: s.substring(i),
data: s.substring(0, i),
};
}
}
return { res: '', data: s };
}
// TODO support e+<int> and e-<int>
function parseNumberValue(s) {
const a = parseIntStr(s);
s = a.res.trim();
let num = a.data;
if (s[0] === '.') {
s = s.substring(1);
const b = parseIntStr(s);
s = b.res.trim();
num = a + '.' + b;
}
return {
res: s,
data: +num,
};
}
function parseNumberType(s) {
const res = parseNumberValue(s);
return {
res: res.res,
data: new LiteralChecker(res.data),
};
}
function getSimpleType(data) {
const type = typeof data;
if (type === 'object') {
if (data === null)
return 'null';
if (Array.isArray(data))
return 'Array';
if (data instanceof Date)
return 'Date';
}
return type;
}
class ArrayChecker extends TypeChecker {
constructor(elementType) {
super();
this.elementType = elementType;
}
get type() {
return `Array<${this.elementType.type}>`;
}
check(data, options) {
if (!Array.isArray(data)) {
throw new TypeCheckError(`expect array, got: ` + getSimpleType(data));
}
for (const datum of data) {
this.elementType.check(datum, options);
}
}
}
function parseArray(s) {
const prefixRes = parseWord(s);
if (prefixRes.data !== 'Array') {
throw new TypeCheckError('expect array type, got: ' + JSON.stringify(s));
}
s = s.substring('Array'.length).trim();
expectChar(s, '<');
s = s.substring(1).trim();
let type;
{
const res = parseType(s);
type = res.data;
s = res.res.trim();
}
expectChar(s, '>');
s = s.substring(1).trim();
return {
res: s,
data: new ArrayChecker(type),
};
}
class BooleanChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'boolean';
}
check(data, options) {
if (typeof data === 'boolean')
return;
if ((options === null || options === void 0 ? void 0 : options.casualBoolean) === true) {
if (data === 0 || data === 1)
return;
}
throw new TypeCheckError('expect boolean, got: ' + JSON.stringify(data));
}
}
class TrueChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'true';
}
check(data, options) {
if (data === true)
return;
if (data === 1 && (options === null || options === void 0 ? void 0 : options.casualBoolean))
return;
throw new TypeCheckError('expect true, got: ' + JSON.stringify(data));
}
}
class FalseChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'false';
}
check(data, options) {
if (data === false)
return;
if (data === 0 && (options === null || options === void 0 ? void 0 : options.casualBoolean))
return;
throw new TypeCheckError('expect false, got: ' + JSON.stringify(data));
}
}
class StringChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'string';
}
check(data, options) {
if (typeof data === 'string')
return;
throw new TypeCheckError('expect string, got: ' + JSON.stringify(data));
}
}
class NumberChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'number';
}
check(data, options) {
if (typeof data === 'number')
return;
throw new TypeCheckError('expect number, got: ' + JSON.stringify(data));
}
}
class DateChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'Date';
}
check(data, options) {
if (data instanceof Date)
return;
throw new TypeCheckError('expect Date, got: ' + JSON.stringify(data));
}
}
class NullChecker extends TypeChecker {
constructor() {
super(...arguments);
this.type = 'null';
}
check(data, options) {
if (data === null)
return;
throw new TypeCheckError('expect null, got: ' + JSON.stringify(data));
}
}
const nativeTypeCheckers = {
string: new StringChecker(),
number: new NumberChecker(),
Date: new DateChecker(),
null: new NullChecker(),
boolean: new BooleanChecker(),
true: new TrueChecker(),
false: new FalseChecker(),
};
function parseOneType(s) {
s = s.trim();
if (s.length === 0) {
throw new Error('empty type');
}
if (s.startsWith('{')) {
devStr('parseObjectType', s);
return parseObjectType(s);
}
for (const typeStr of [
'string',
'number',
'Date',
'null',
'boolean',
'true',
'false',
]) {
if (s.startsWith(typeStr)) {
const nextC = s[typeStr.length];
if (!isWordChar(nextC)) {
const type = nativeTypeCheckers[typeStr];
return {
res: s.substring(typeStr.length),
data: type,
};
}
}
}
switch (s[0]) {
case '"':
case "'": {
return parseStringType(s);
}
default: {
if (isDigit(s[0])) {
return parseNumberType(s);
}
}
}
{
devStr('parseWord:', s);
const prefixRes = parseWord(s);
if (prefixRes.data === 'Array') {
return parseArray(s);
}
return {
res: prefixRes.res,
data: new LiteralChecker(prefixRes.data),
};
}
}
function parseObjectKey(s) {
s = s.trim();
if (s.length === 0) {
throw new Error('incomplete type, expect object key');
}
switch (s[0]) {
case '"':
case "'": {
return parseStringValue(s);
}
default: {
const match = s.match(/^\w+/);
if (!match)
throw new Error('invalid object key, got: ' + JSON.stringify(s));
const data = match[0];
const res = s.substring(data.length);
return { res, data };
}
}
}
function toObjectTypeChecker(type) {
if (type instanceof ObjectTypeChecker) {
return type;
}
if (type instanceof BracketTypeChecker &&
type.content instanceof ObjectTypeChecker) {
return type.content;
}
throw new Error('expect object type checker');
}
function isObjectTypeChecker(type) {
return !!toObjectTypeChecker(type);
}
class OrTypeChecker extends TypeChecker {
constructor(left, right) {
super();
this.left = left;
this.right = right;
}
get type() {
return this.left.type + ' | ' + this.right.type;
}
check(data, options) {
const errors = [];
for (const type of [this.left, this.right]) {
try {
type.check(data, options);
return;
}
catch (e) {
errors.push(e);
}
}
OrTypeChecker.lastErrors = errors;
throw new TypeCheckError(`failed all type check of OrType, type: ${this.type}, errors: ${errors
.map(e => e.toString())
.join(' | ')}`);
}
compile() {
return new OrTypeChecker(this.left.compile(), this.right.compile());
}
}
class AndTypeChecker extends TypeChecker {
constructor(left, right) {
super();
this.left = left;
this.right = right;
}
get type() {
return this.left.type + ' & ' + this.right.type;
}
check(data, options) {
const type = this.compile();
if (type !== this) {
// throw new Error('not compiled');
return type.check(data, options);
}
this.left.check(data, options);
this.right.check(data, options);
}
compile() {
if ('dev') {
if (this.left instanceof OrTypeChecker) {
return new OrTypeChecker(new AndTypeChecker(this.left.left, this.right).compile(), new AndTypeChecker(this.left.right, this.right).compile()).compile();
}
if (this.left instanceof BracketTypeChecker &&
this.left.content instanceof OrTypeChecker) {
return new OrTypeChecker(new AndTypeChecker(this.left.content.left, this.right).compile(), new AndTypeChecker(this.left.content.right, this.right).compile()).compile();
}
if (this.right instanceof OrTypeChecker) {
return new OrTypeChecker(new AndTypeChecker(this.left, this.right.left).compile(), new AndTypeChecker(this.left, this.right.right).compile()).compile();
}
if (this.right instanceof BracketTypeChecker &&
this.right.content instanceof OrTypeChecker) {
return new OrTypeChecker(new AndTypeChecker(this.left, this.right.content.left).compile(), new AndTypeChecker(this.left, this.right.content.right).compile()).compile();
}
if (isObjectTypeChecker(this.left) && isObjectTypeChecker(this.right)) {
return toObjectTypeChecker(this.left).and(toObjectTypeChecker(this.right));
}
}
return this;
}
}
/**
* & has higher binding order than |
* */
class LogicTypeChecker extends TypeChecker {
constructor(terms) {
super();
this.terms = terms;
}
get type() {
return this.terms
.map(term => (typeof term === 'string' ? term : term.type))
.join(' ');
}
compile() {
let xs = this.terms;
const merge = (xs, op) => {
const ys = [];
for (let i = 0; i < xs.length; i++) {
const curr = xs[i];
if (curr === op) {
const left = ys.pop();
if (!left || typeof left === 'string') {
throw new Error(`missing left-hand-side term for logical op: ` + op);
}
const right = xs[i + 1];
i++;
if (!right || typeof right === 'string') {
throw new Error(`missing right-hand-side term for logical op: ` + op);
}
switch (op) {
case '&':
// ys.push(new AndTypeChecker(left, right).compile());
ys.push(new AndTypeChecker(left, right));
break;
case '|':
// ys.push(new OrTypeChecker(left, right).compile());
ys.push(new OrTypeChecker(left, right));
break;
default:
throw new Error(`unknown logical op: ${JSON.stringify(op)}`);
}
continue;
}
ys.push(curr);
}
return ys;
};
// merge all &
xs = merge(xs, '&');
// merge all |
xs = merge(xs, '|');
if (xs.length !== 1) {
throw new Error(`incomplete logical terms: ${this.type}`);
}
const term = xs[0];
if (typeof term === 'string') {
throw new Error(`incomplete logical terms: ${this.type}`);
}
return term;
}
check(data, options) {
this.compile().check(data, options);
}
}
class BracketTypeChecker extends TypeChecker {
constructor(content) {
super();
this.content = content;
}
get type() {
return `(${this.content.type})`;
}
check(data, options) {
this.content.check(data, options);
}
compile() {
return new BracketTypeChecker(this.content.compile());
}
}
function compileBracket(terms) {
const stack = [];
for (const term of terms) {
switch (term) {
case ')':
const idx = stack.lastIndexOf('(');
if (idx === -1) {
console.error('missing open bracket, terms:', terms);
throw new Error('missing open bracket');
}
const logicTerms = stack.splice(idx + 1, stack.length);
stack.pop();
stack.push(new BracketTypeChecker(new LogicTypeChecker(logicTerms).compile()));
break;
default:
stack.push(term);
}
}
return stack;
}
function parseType(s) {
const originalS = s;
const terms = [];
let isTerm = false;
s = s.trim();
main: for (; s.length > 0;) {
const c = s[0];
switch (c) {
case '(':
case ')':
case '|':
case '&': {
isTerm = true;
s = s.substring(1).trim();
terms.push(c);
break;
}
default:
if (terms.length === 1) {
const first = terms[0];
if (typeof first !== 'string') {
return {
res: s,
data: first,
};
}
}
if (s[0] === ':') {
let last = terms.pop();
if (last) {
let lastType = typeof last === 'string' ? last : last.type;
s = lastType + s;
break main;
}
devStr('over done?', JSON.stringify({ originalS, terms, isTerm, s }));
}
if ((s[0] === '}' || s[0] === ';' || s[0] === ',') &&
terms.length > 0) {
break main;
}
devStr('parseOneType:', s);
const res = parseOneType(s);
s = res.res.trim();
terms.push(res.data);
}
}
if (!isTerm) {
return parseOneType(originalS);
}
const type = new LogicTypeChecker(compileBracket(terms)).compile();
return {
res: s,
data: type,
};
}
function parseTsType(type) {
switch (type) {
case 'string':
case 'number':
case 'Date':
case 'null':
case 'boolean':
case 'true':
case 'false':
return nativeTypeCheckers[type];
}
const res = parseType(type);
if (res.res !== '') {
console.error('unknown type:', type);
throw new Error(`failed to parse type, reminding type string: '${res.res}'`);
}
return res.data.compile();
}
exports.parseTsType = parseTsType;
/**
* only check for json-compatible types
*
* @throws TypeCheckError if failed
* */
function checkTsType(type, data, options) {
let typeChecker = parseTsType(type);
typeChecker.check(data, options);
}
exports.checkTsType = checkTsType;