UNPKG

maketypes

Version:

Make TypeScript types and proxy objects from example JSON objects. Can use proxy objects to dynamically type check JSON at runtime.

639 lines (605 loc) 17.5 kB
import {default as Emitter, emitProxyTypeCheck} from './emit'; export const enum BaseShape { BOTTOM, NULL, RECORD, STRING, BOOLEAN, NUMBER, COLLECTION, ANY } export type Shape = CBottomShape | CNullShape | CRecordShape | CStringShape | CBooleanShape | CNumberShape | CCollectionShape | CAnyShape; export const enum ContextType { ENTITY, FIELD } function pascalCase(n: string): string { return n.split("_").map((s) => (s[0] ? s[0].toUpperCase() : "") + s.slice(1)).join(""); } export function getReferencedRecordShapes(e: Emitter, s: Set<CRecordShape>, sh: Shape): void { switch (sh.type) { case BaseShape.RECORD: if (!s.has(sh)) { s.add(sh); sh.getReferencedRecordShapes(e, s); } break; case BaseShape.COLLECTION: getReferencedRecordShapes(e, s, sh.baseShape); break; case BaseShape.ANY: sh.getDistilledShapes(e).forEach((sh) => getReferencedRecordShapes(e, s, sh)); break; } } export class FieldContext { public get type(): ContextType.FIELD { return ContextType.FIELD; } public readonly parent: CRecordShape; public readonly field: string; constructor(parent: CRecordShape, field: string) { this.parent = parent; this.field = field; } public getName(e: Emitter): string { const name = pascalCase(this.field); return name; } } export class EntityContext { public get type(): ContextType.ENTITY { return ContextType.ENTITY; } public readonly parent: CCollectionShape; constructor(parent: CCollectionShape) { this.parent = parent; } public getName(e: Emitter): string { return `${this.parent.getName(e)}Entity`; } } export type Context = FieldContext | EntityContext; export class CBottomShape { public get type(): BaseShape.BOTTOM { return BaseShape.BOTTOM; } public get nullable(): boolean { return false; } public makeNullable(): CBottomShape { throw new TypeError(`Doesn't make sense.`); } public makeNonNullable(): CBottomShape { return this; } public emitType(e: Emitter): void { throw new Error(`Doesn't make sense.`); } public getProxyType(e: Emitter): string { throw new Error(`Doesn't make sense.`); } public equal(t: Shape): boolean { return this === t; } } export const BottomShape = new CBottomShape(); export class CNullShape { public get nullable(): boolean { return true; } public get type(): BaseShape.NULL { return BaseShape.NULL; } public makeNullable(): CNullShape { return this; } public makeNonNullable(): CNullShape { return this; } public emitType(e: Emitter): void { e.interfaces.write("null"); } public getProxyType(e: Emitter): string { return "null"; } public equal(t: Shape): boolean { return this === t; } } export const NullShape = new CNullShape(); export class CNumberShape { public get nullable(): boolean { return this === NullableNumberShape; } public get type(): BaseShape.NUMBER { return BaseShape.NUMBER; } public makeNullable(): CNumberShape { return NullableNumberShape; } public makeNonNullable(): CNumberShape { return NumberShape; } public emitType(e: Emitter): void { e.interfaces.write(this.getProxyType(e)); } public getProxyType(e: Emitter): string { let rv = "number"; if (this.nullable) { rv += " | null"; } return rv; } public equal(t: Shape): boolean { return this === t; } } export const NumberShape = new CNumberShape(); export const NullableNumberShape = new CNumberShape(); export class CStringShape { public get type(): BaseShape.STRING { return BaseShape.STRING; } public get nullable(): boolean { return this === NullableStringShape; } public makeNullable(): CStringShape { return NullableStringShape; } public makeNonNullable(): CStringShape { return StringShape; } public emitType(e: Emitter): void { e.interfaces.write(this.getProxyType(e)); } public getProxyType(e: Emitter): string { let rv = "string"; if (this.nullable) { rv += " | null"; } return rv; } public equal(t: Shape): boolean { return this === t; } } export const StringShape = new CStringShape(); export const NullableStringShape = new CStringShape(); export class CBooleanShape { public get type(): BaseShape.BOOLEAN { return BaseShape.BOOLEAN; } public get nullable(): boolean { return this === NullableBooleanShape; } public makeNullable(): CBooleanShape { return NullableBooleanShape; } public makeNonNullable(): CBooleanShape { return BooleanShape; } public emitType(e: Emitter): void { e.interfaces.write(this.getProxyType(e)); } public getProxyType(e: Emitter): string { let rv = "boolean"; if (this.nullable) { rv += " | null"; } return rv; } public equal(t: Shape): boolean { return this === t; } } export const BooleanShape = new CBooleanShape(); export const NullableBooleanShape = new CBooleanShape(); export class CAnyShape { public get type(): BaseShape.ANY { return BaseShape.ANY; } private readonly _shapes: Shape[]; private readonly _nullable: boolean = false; private _hasDistilledShapes: boolean = false; private _distilledShapes: Shape[] = []; constructor(shapes: Shape[], nullable: boolean) { this._shapes = shapes; this._nullable = nullable; } public get nullable(): boolean { return this._nullable === true; } public makeNullable(): CAnyShape { if (this._nullable) { return this; } else { return new CAnyShape(this._shapes, true); } } public makeNonNullable(): CAnyShape { if (this._nullable) { return new CAnyShape(this._shapes, false); } else { return this; } } private _ensureDistilled(e: Emitter): void { if (!this._hasDistilledShapes) { let shapes = new Map<BaseShape, Shape[]>(); for (let i = 0; i < this._shapes.length; i++) { const s = this._shapes[i]; if (!shapes.has(s.type)) { shapes.set(s.type, []); } shapes.get(s.type)!.push(s); } shapes.forEach((shapes, key) => { let shape: Shape = BottomShape; for (let i = 0; i < shapes.length; i++) { shape = csh(e, shape, shapes[i]); } this._distilledShapes.push(shape); }); this._hasDistilledShapes = true; } } public getDistilledShapes(e: Emitter): Shape[] { this._ensureDistilled(e); return this._distilledShapes; } public addToShapes(shape: Shape): CAnyShape { const shapeClone = this._shapes.slice(0); shapeClone.push(shape); return new CAnyShape(shapeClone, this._nullable); } public emitType(e: Emitter): void { this._ensureDistilled(e); this._distilledShapes.forEach((s, i) => { s.emitType(e); if (i < this._distilledShapes.length - 1) { e.interfaces.write(" | "); } }); } public getProxyType(e: Emitter): string { this._ensureDistilled(e); return this._distilledShapes.map((s) => s.getProxyType(e)).join(" | "); } public equal(t: Shape): boolean { return this === t; } } export class CRecordShape { public get type(): BaseShape.RECORD { return BaseShape.RECORD; } private readonly _nullable: boolean; private readonly _fields: Map<string, Shape>; public readonly contexts: Context[]; private _name: string | null = null; private constructor(fields: Map<string, Shape>, nullable: boolean, contexts: Context[]) { // Assign a context to all fields. const fieldsWithContext = new Map<string, Shape>(); fields.forEach((val, index) => { if (val.type === BaseShape.RECORD || val.type === BaseShape.COLLECTION) { fieldsWithContext.set(index, val.addContext(new FieldContext(this, index))); } else { fieldsWithContext.set(index, val); } }); this._fields = fieldsWithContext; this._nullable = nullable; this.contexts = contexts; } public get nullable(): boolean { return this._nullable; } /** * Construct a new record shape. Returns an existing, equivalent record shape * if applicable. */ public static Create(e: Emitter, fields: Map<string, Shape>, nullable: boolean, contexts: Context[] = []): CRecordShape { const record = new CRecordShape(fields, nullable, contexts); return e.registerRecordShape(record); } public makeNullable(): CRecordShape { if (this._nullable) { return this; } else { return new CRecordShape(this._fields, true, this.contexts); } } public addContext(ctx: Context): CRecordShape { this.contexts.push(ctx); return this; } public makeNonNullable(): CRecordShape { if (this._nullable) { return new CRecordShape(this._fields, false, this.contexts); } else { return this; } } public forEachField(cb: (t: Shape, name: string) => any): void { this._fields.forEach(cb); } public getField(name: string): Shape { const t = this._fields.get(name); if (!t) { return NullShape; } else { return t; } } public equal(t: Shape): boolean { if (t.type === BaseShape.RECORD && this._nullable === t._nullable && this._fields.size === t._fields.size) { let rv = true; const tFields = t._fields; // Check all fields. // NOTE: Since size is equal, no need to iterate over t. Either they have the same fields // or t is missing fields from this one. this.forEachField((t, name) => { if (rv) { const field = tFields.get(name); if (field) { rv = field.equal(t); } else { rv = false; } } }); return rv; } return false; } public emitType(e: Emitter): void { e.interfaces.write(this.getName(e)); if (this.nullable) { e.interfaces.write(" | null"); } } public getProxyClass(e: Emitter): string { return `${this.getName(e)}Proxy`; } public getProxyType(e: Emitter): string { let rv = `${this.getName(e)}Proxy`; if (this.nullable) { rv += " | null"; } return rv; } public emitInterfaceDefinition(e: Emitter): void { const w = e.interfaces; w.write(`export interface ${this.getName(e)} {`).endl(); this.forEachField((t, name) => { w.tab(1).write(name); if (t.nullable) { w.write("?"); } w.write(": "); t.emitType(e); w.write(";").endl(); }); w.write(`}`); } public emitProxyClass(e: Emitter): void { const w = e.proxies; w.writeln(`export class ${this.getProxyClass(e)} {`); this.forEachField((t, name) => { w.tab(1).writeln(`public readonly ${name}: ${t.getProxyType(e)};`); }); w.tab(1).writeln(`public static Parse(d: string): ${this.getProxyType(e)} {`); w.tab(2).writeln(`return ${this.getProxyClass(e)}.Create(JSON.parse(d));`); w.tab(1).writeln(`}`); w.tab(1).writeln(`public static Create(d: any, field: string = 'root'): ${this.getProxyType(e)} {`); w.tab(2).writeln(`if (!field) {`); w.tab(3).writeln(`obj = d;`); w.tab(3).writeln(`field = "root";`); w.tab(2).writeln(`}`); w.tab(2).writeln(`if (d === null || d === undefined) {`); w.tab(3); if (this.nullable) { w.writeln(`return null;`); } else { e.markHelperAsUsed('throwNull2NonNull'); w.writeln(`throwNull2NonNull(field, d);`); } w.tab(2).writeln(`} else if (typeof(d) !== 'object') {`); e.markHelperAsUsed('throwNotObject'); w.tab(3).writeln(`throwNotObject(field, d, ${this.nullable});`); w.tab(2).writeln(`} else if (Array.isArray(d)) {`) e.markHelperAsUsed('throwIsArray'); w.tab(3).writeln(`throwIsArray(field, d, ${this.nullable});`); w.tab(2).writeln(`}`); // At this point, we know we have a non-null object. // Check all fields. this.forEachField((t, name) => { emitProxyTypeCheck(e, w, t, 2, `d.${name}`, `field + ".${name}"`); }); w.tab(2).writeln(`return new ${this.getProxyClass(e)}(d);`); w.tab(1).writeln(`}`); w.tab(1).writeln(`private constructor(d: any) {`); // Emit an assignment for each field. this.forEachField((t, name) => { w.tab(2).writeln(`this.${name} = d.${name};`); }); w.tab(1).writeln(`}`); w.writeln('}'); } public getReferencedRecordShapes(e: Emitter, rv: Set<CRecordShape>): void { this.forEachField((t, name) => { getReferencedRecordShapes(e, rv, t); }); } public markAsRoot(name: string): void { this._name = name; } public getName(e: Emitter): string { if (typeof(this._name) === 'string') { return this._name; } // Calculate unique name. const nameSet = new Set<string>(); let name = this.contexts .map((c) => c.getName(e)) // Remove duplicate names. .filter((n) => { if (!nameSet.has(n)) { nameSet.add(n); return true; } return false; }) .join("Or"); this._name = e.registerName(name); return this._name; } } export class CCollectionShape { public get type(): BaseShape.COLLECTION { return BaseShape.COLLECTION; } public readonly baseShape: Shape; public readonly contexts: Context[]; private _name: string | null = null; constructor(baseShape: Shape, contexts: Context[] = []) { // Add context if a record/collection. this.baseShape = (baseShape.type === BaseShape.RECORD || baseShape.type === BaseShape.COLLECTION) ? baseShape.addContext(new EntityContext(this)) : baseShape; this.contexts = contexts; } public get nullable(): boolean { return true; } public makeNullable(): CCollectionShape { return this; } public makeNonNullable(): CCollectionShape { return this; } public addContext(ctx: Context): CCollectionShape { this.contexts.push(ctx); return this; } public emitType(e: Emitter): void { e.interfaces.write("("); this.baseShape.emitType(e); e.interfaces.write(")[] | null"); } public getProxyType(e: Emitter): string { const base = this.baseShape.getProxyType(e); if (base.indexOf("|") !== -1) { return `(${base})[] | null`; } else { return `${base}[] | null`; } } public equal(t: Shape): boolean { return t.type === BaseShape.COLLECTION && this.baseShape.equal(t.baseShape); } public getName(e: Emitter): string { if (typeof(this._name) === 'string') { return this._name; } const nameSet = new Set<string>(); // No need to make collection names unique. this._name = this.contexts .map((c) => c.getName(e)) .filter((name) => { if (!nameSet.has(name)) { nameSet.add(name); return true; } return false; }) .join("Or"); return this._name; } } export function csh(e: Emitter, s1: Shape, s2: Shape): Shape { // csh(σ, σ) = σ if (s1 === s2) { return s1; } if (s1.type === BaseShape.COLLECTION && s2.type === BaseShape.COLLECTION) { // csh([σ1], [σ2]) = [csh(σ1, σ2)] return new CCollectionShape(csh(e, s1.baseShape, s2.baseShape)); } // csh(⊥, σ) = csh(σ, ⊥) = σ if (s1.type === BaseShape.BOTTOM) { return s2; } if (s2.type === BaseShape.BOTTOM) { return s1; } // csh(null, σ) = csh(σ, null) = nullable<σ> if (s1.type === BaseShape.NULL) { return s2.makeNullable(); } if (s2.type === BaseShape.NULL) { return s1.makeNullable(); } // csh(any, σ) = csh(σ, any) = any if (s1.type === BaseShape.ANY) { return s1.addToShapes(s2); } if (s2.type === BaseShape.ANY) { return s2.addToShapes(s1); } // csh(σ2, nullable<σˆ1> ) = csh(nullable<σˆ1> , σ2) = nullable<csh(σˆ1, σ2)> if (s1.nullable && s1.type !== BaseShape.COLLECTION) { return csh(e, s1.makeNonNullable(), s2).makeNullable(); } if (s2.nullable && s2.type !== BaseShape.COLLECTION) { return csh(e, s2.makeNonNullable(), s1).makeNullable(); } // (recd) rule if (s1.type === BaseShape.RECORD && s2.type === BaseShape.RECORD) { // Get all fields. const fields = new Map<string, Shape>(); s1.forEachField((t, name) => { fields.set(name, csh(e, t, s2.getField(name))); }); s2.forEachField((t, name) => { if (!fields.has(name)) { fields.set(name, csh(e, t, s1.getField(name))); } }); return CRecordShape.Create(e, fields, false); } // (any) rule return new CAnyShape([s1, s2], s1.nullable || s2.nullable); } export function d2s(e: Emitter, d: any): Shape { if (d === undefined || d === null) { return NullShape; } switch (typeof(d)) { case 'number': return NumberShape; case 'string': return StringShape; case 'boolean': return BooleanShape; } // Must be an object or array. if (Array.isArray(d)) { // Empty array: Not enough information to figure out a precise type. if (d.length === 0) { return new CCollectionShape(NullShape); } let t: Shape = BottomShape; for (let i = 0; i < d.length; i++) { t = csh(e, t, d2s(e, d[i])); } return new CCollectionShape(t); } const keys = Object.keys(d); const fields = new Map<string, Shape>(); for (let i = 0; i < keys.length; i++) { const name = keys[i]; fields.set(name, d2s(e, d[name])); } return CRecordShape.Create(e, fields, false); }