From cff1a3d3188ae838dea56cb6527478a467c02e10 Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Mon, 22 Feb 2021 12:51:17 +0100 Subject: [PATCH] Exploration of TypeScript typed Records --- implementations/javascript/src/annotated.ts | 4 +- implementations/javascript/src/codec.ts | 2 +- implementations/javascript/src/fold.ts | 6 +- implementations/javascript/src/record.ts | 134 ++++++++++-------- implementations/javascript/src/tuple.ts | 15 ++ implementations/javascript/src/values.ts | 4 +- implementations/javascript/test/codec.test.ts | 29 ++-- 7 files changed, 112 insertions(+), 82 deletions(-) create mode 100644 implementations/javascript/src/tuple.ts diff --git a/implementations/javascript/src/annotated.ts b/implementations/javascript/src/annotated.ts index b43b88d..c8976c3 100644 --- a/implementations/javascript/src/annotated.ts +++ b/implementations/javascript/src/annotated.ts @@ -69,9 +69,9 @@ export function strip(v: Value, depth: num function walk(v: Value): Value { return step(v, nextDepth); } if (Record.isRecord(v.item)) { - return new Record(step(v.item.label, depth), v.item.map(walk)); + return Record(step(v.item.label, depth), v.item.map(walk)); } else if (Array.isArray(v.item)) { - return v.item.map(walk); + return (v.item as Array>).map(walk); } else if (Set.isSet(v.item)) { return v.item.map(walk); } else if (Dictionary.isDictionary, T>(v.item)) { diff --git a/implementations/javascript/src/codec.ts b/implementations/javascript/src/codec.ts index ff8f076..72ad745 100644 --- a/implementations/javascript/src/codec.ts +++ b/implementations/javascript/src/codec.ts @@ -178,7 +178,7 @@ export class Decoder { case Tag.Record: { const vs = this.nextvalues(); if (vs.length === 0) throw new DecodeError("Too few elements in encoded record"); - return this.wrap(new Record(vs[0], vs.slice(1))); + return this.wrap(Record(vs[0], vs.slice(1))); } case Tag.Sequence: return this.wrap(this.nextvalues()); case Tag.Set: return this.wrap(new Set(this.nextvalues())); diff --git a/implementations/javascript/src/fold.ts b/implementations/javascript/src/fold.ts index de98672..a1763ec 100644 --- a/implementations/javascript/src/fold.ts +++ b/implementations/javascript/src/fold.ts @@ -11,7 +11,7 @@ export interface FoldMethods { bytes(b: Bytes): R; symbol(s: symbol): R; - record(r: Record, k: Fold): R; + record(r: Record, k: Fold): R; array(a: Array>, k: Fold): R; set(s: Set, k: Fold): R; dictionary(d: Dictionary, T>, k: Fold): R; @@ -43,8 +43,8 @@ export abstract class ValueFold implemen symbol(s: symbol): Value { return s; } - record(r: Record, k: Fold>): Value { - return new Record(k(r.label), r.map(k)); + record(r: Record, k: Fold>): Value { + return Record(k(r.label), r.map(k)); } array(a: Value[], k: Fold>): Value { return a.map(k); diff --git a/implementations/javascript/src/record.ts b/implementations/javascript/src/record.ts index 2a02039..8760428 100644 --- a/implementations/javascript/src/record.ts +++ b/implementations/javascript/src/record.ts @@ -1,14 +1,78 @@ import { Tag } from "./constants"; import { Encoder } from "./codec"; import { PreserveOn } from "./symbols"; -import { DefaultPointer, fromJS, is, Value } from "./values"; +import { DefaultPointer, is, Value } from "./values"; +import { Tuple, TupleMap } from "./tuple"; export const IsPreservesRecord = Symbol.for('IsPreservesRecord'); -export class Record extends Array> { +export type Record, FieldsType extends Tuple>, T extends object = DefaultPointer> + = { readonly label: LabelType } & FieldsType & RecordImpl; + +export interface RecordConstructor, FieldsType extends Tuple>, T extends object = DefaultPointer> { + (...fields: FieldsType): Record; + constructorInfo: RecordConstructorInfo; + isClassOf(v: any): v is Record; + _: { [getter: string]: (r: any) => Value | undefined }; +} + +export interface RecordConstructorInfo, T extends object = DefaultPointer> { + label: LabelType; + arity: number; +} + +export function Record, FieldsType extends Tuple>, T extends object = DefaultPointer> + (label: LabelType, fields: FieldsType) +: Record +{ + return new RecordImpl(label, fields) as unknown as Record; +} + +export namespace Record { + export function fallbackToString(_f: Value): string { + return ''; + } + + export function makeConstructor(): , FieldNamesType extends Tuple, FieldsType extends TupleMap>>(label: LabelType, fieldNames: FieldNamesType) => RecordConstructor { + return , FieldNamesType extends Tuple, FieldsType extends TupleMap>>(label: LabelType, fieldNames: FieldNamesType) => { + const arity = fieldNames.length; + const ctor: RecordConstructor = (...fields: FieldsType): Record => { + if (fields.length !== arity) { + throw new Error("Record: cannot instantiate " + (label && (label as any).toString()) + + " expecting " + arity + " fields with " + fields.length + " fields"); + } + return Record(label, fields); + }; + const constructorInfo = { label, arity }; + ctor.constructorInfo = constructorInfo; + ctor.isClassOf = (v: any): v is Record => Record.isClassOf(constructorInfo, v); + ctor._ = {}; + fieldNames.forEach((name, i) => { + ctor._[name] = function (r: any): Value | undefined { + if (!ctor.isClassOf(r)) { + throw new Error("Record: attempt to retrieve field "+(label as any).toString()+"."+name+ + " from non-"+(label as any).toString()+": "+(r && r.toString())); + } + return r[i]; + }; + }); + return ctor; + }; + } + + export function isRecord(x: any): x is Record, Tuple>, T> { + return !!x?.[IsPreservesRecord]; + } + + export function isClassOf, FieldsType extends Tuple>, T extends object = DefaultPointer>(ci: RecordConstructorInfo, v: any): v is Record { + return (Record.isRecord(v)) && is(ci.label, v.label) && (ci.arity === v.length); + } +} + +export class RecordImpl extends Array> { readonly label: Value; - constructor(label: Value, fieldsJS: any[]) { + constructor(label: Value, fields: Array>) { if (arguments.length === 1) { // Using things like someRecord.map() involves the runtime // apparently instantiating instances of this.constructor @@ -20,8 +84,8 @@ export class Record extends Array> { return; } - super(fieldsJS.length); - fieldsJS.forEach((f, i) => this[i] = fromJS(f)); + super(fields.length); + fields.forEach((f, i) => this[i] = f); this.label = label; Object.freeze(this); } @@ -30,18 +94,18 @@ export class Record extends Array> { return (index < this.length) ? this[index] : defaultValue; } - set(index: number, newValue: Value): Record { - return new Record(this.label, this.map((f, i) => (i === index) ? newValue : f)); + set(index: number, newValue: Value): this { + return new RecordImpl(this.label, this.map((f, i) => (i === index) ? newValue : f)); } - getConstructorInfo(): RecordConstructorInfo { + getConstructorInfo(): RecordConstructorInfo, T> { return { label: this.label, arity: this.length }; } equals(other: any): boolean { return Record.isRecord(other) && is(this.label, other.label) && - this.every((f, i) => is(f, other.get(i))); + this.every((f, i) => is(f, other[i])); } // hashCode(): number { @@ -50,8 +114,6 @@ export class Record extends Array> { // return h; // } - static fallbackToString: (f: Value) => string = (_f) => ''; - toString(): string { return this.asPreservesText(); } @@ -71,36 +133,6 @@ export class Record extends Array> { }).join(', ') + ')'; } - static makeConstructor(labelSymbolText: string, fieldNames: string[]): RecordConstructor { - return Record.makeBasicConstructor(Symbol.for(labelSymbolText), fieldNames); - } - - static makeBasicConstructor(label0: any, fieldNames: string[]): RecordConstructor { - const label = fromJS(label0); - const arity = fieldNames.length; - const ctor: RecordConstructor = (...fields: any[]): Record => { - if (fields.length !== arity) { - throw new Error("Record: cannot instantiate " + (label && label.toString()) + - " expecting " + arity + " fields with " + fields.length + " fields"); - } - return new Record(label, fields); - }; - const constructorInfo = { label, arity }; - ctor.constructorInfo = constructorInfo; - ctor.isClassOf = (v: any): v is Record => Record.isClassOf(constructorInfo, v); - ctor._ = {}; - fieldNames.forEach((name, i) => { - ctor._[name] = function (r: any): Value | undefined { - if (!ctor.isClassOf(r)) { - throw new Error("Record: attempt to retrieve field "+label.toString()+"."+name+ - " from non-"+label.toString()+": "+(r && r.toString())); - } - return r.get(i); - }; - }); - return ctor; - } - [PreserveOn](encoder: Encoder) { encoder.emitbyte(Tag.Record); encoder.push(this.label); @@ -111,24 +143,4 @@ export class Record extends Array> { get [IsPreservesRecord](): boolean { return true; } - - static isRecord(x: any): x is Record { - return !!x?.[IsPreservesRecord]; - } - - static isClassOf(ci: RecordConstructorInfo, v: any): v is Record { - return (Record.isRecord(v)) && is(ci.label, v.label) && (ci.arity === v.length); - } -} - -export interface RecordConstructor { - (...fields: any[]): Record; - constructorInfo: RecordConstructorInfo; - isClassOf(v: any): v is Record; - _: { [getter: string]: (r: any) => Value | undefined }; -} - -export interface RecordConstructorInfo { - label: Value; - arity: number; } diff --git a/implementations/javascript/src/tuple.ts b/implementations/javascript/src/tuple.ts new file mode 100644 index 0000000..9c3e46d --- /dev/null +++ b/implementations/javascript/src/tuple.ts @@ -0,0 +1,15 @@ +// This Tuple type (and tuple() function) is a hack to induce +// TypeScript to infer tuple types rather than array types. (Source: +// https://github.com/microsoft/TypeScript/issues/27179#issuecomment-422606990) +// +// Without it, [123, 'hi', true] will often get the type (string | +// number | boolean)[] instead of [number, string, boolean]. + +export type Tuple = T[] | [T]; +export const tuple = >(... args: A) => args; + +export const Tuple = Array; + +export type TupleMap, V> = Tuple & { + [K in keyof T]: V; +} diff --git a/implementations/javascript/src/values.ts b/implementations/javascript/src/values.ts index 87ee80b..8d732df 100644 --- a/implementations/javascript/src/values.ts +++ b/implementations/javascript/src/values.ts @@ -6,18 +6,20 @@ import { DoubleFloat, SingleFloat } from './float'; import { Record } from './record'; import { Annotated } from './annotated'; import { Set, Dictionary } from './dictionary'; +import { Tuple } from './tuple'; export * from './bytes'; export * from './float'; export * from './record'; export * from './annotated'; export * from './dictionary'; +export * from './tuple'; export type DefaultPointer = object export type Value = Atom | Compound | T | Annotated; export type Atom = boolean | SingleFloat | DoubleFloat | number | string | Bytes | symbol; -export type Compound = Record | Array> | Set | Dictionary, T>; +export type Compound = Record, T> | Array> | Set | Dictionary, T>; export function fromJS(x: any): Value { switch (typeof x) { diff --git a/implementations/javascript/test/codec.test.ts b/implementations/javascript/test/codec.test.ts index 10eeff3..516779b 100644 --- a/implementations/javascript/test/codec.test.ts +++ b/implementations/javascript/test/codec.test.ts @@ -9,6 +9,7 @@ import { preserves, fromJS, Constants, + tuple, } from '../src/index'; const { Tag } = Constants; import './test-utils'; @@ -35,9 +36,9 @@ function encodePointer(w: Pointer): Value { return w.v; } -const Discard = Record.makeConstructor('discard', []); -const Capture = Record.makeConstructor('capture', ['pattern']); -const Observe = Record.makeConstructor('observe', ['pattern']); +const Discard = Record.makeConstructor()(Symbol.for('discard'), []); +const Capture = Record.makeConstructor()(Symbol.for('capture'), ['pattern']); +const Observe = Record.makeConstructor()(Symbol.for('observe'), ['pattern']); describe('record constructors', () => { it('should have constructorInfo', () => { @@ -51,8 +52,8 @@ describe('record constructors', () => { }) describe('RecordConstructorInfo', () => { - const C1 = Record.makeBasicConstructor([1], ['x', 'y']); - const C2 = Record.makeBasicConstructor([1], ['z', 'w']); + const C1 = Record.makeConstructor()(tuple(1), ['x', 'y']); + const C2 = Record.makeConstructor()(tuple(1), ['z', 'w']); it('instance comparison should ignore pointer and fieldname differences', () => { expect(C1(9,9)).is(C2(9,9)); expect(C1(9,9)).not.is(C2(9,8)); @@ -167,7 +168,7 @@ describe('common test suite', () => { const samples_bin = fs.readFileSync(__dirname + '/../../../tests/samples.bin'); const samples = decodeWithAnnotations(samples_bin, { decodePointer }); - const TestCases = Record.makeConstructor('TestCases', ['cases']); + const TestCases = Record.makeConstructor()(Symbol.for('TestCases'), ['cases']); function DS(bs: Bytes) { return decode(bs, { decodePointer }); @@ -192,21 +193,21 @@ describe('common test suite', () => { annotate(2, 1), annotate(4, 3)), back: 5 }, - annotation5: { forward: annotate(new Record(Symbol.for('R'), - [annotate(Symbol.for('f'), - Symbol.for('af'))]), + annotation5: { forward: annotate(Record(Symbol.for('R'), + [annotate(Symbol.for('f'), + Symbol.for('af'))]), Symbol.for('ar')), - back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, - annotation6: { forward: new Record(annotate(Symbol.for('R'), + back: Record(Symbol.for('R'), [Symbol.for('f')]) }, + annotation6: { forward: Record(annotate(Symbol.for('R'), Symbol.for('ar')), [annotate(Symbol.for('f'), Symbol.for('af'))]), - back: new Record(Symbol.for('R'), [Symbol.for('f')]) }, + back: Record(Symbol.for('R'), [Symbol.for('f')]) }, annotation7: { forward: annotate([], Symbol.for('a'), Symbol.for('b'), Symbol.for('c')), back: [] }, list1: { forward: [1, 2, 3, 4], back: [1, 2, 3, 4] }, - record2: { value: Observe(new Record(Symbol.for("speak"), [ + record2: { value: Observe(Record(Symbol.for("speak"), [ Discard(), Capture(Discard()) ])) }, @@ -244,7 +245,7 @@ describe('common test suite', () => { const tests = peel(TestCases._.cases(peel(samples))!) as Dictionary, Pointer>; tests.forEach((t0: Value, tName0: Value) => { const tName = Symbol.keyFor(strip(tName0) as symbol)!; - const t = peel(t0) as Record; + const t = peel(t0) as Record; switch (t.label) { case Symbol.for('Test'): runTestCase('normal', tName, strip(t[0]) as Bytes, t[1]);