UNPKG

ts-type-check

Version:

Check json value based of Typescript type in string

752 lines (751 loc) 22.1 kB
"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;