From 329cee7bd6655ce09336802c545fbcd9bf8d35f7 Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Mon, 11 Jan 2021 16:54:52 +0100 Subject: [PATCH] Much better duck typing --- implementations/javascript/src/codec.ts | 43 +++--- implementations/javascript/src/flex.ts | 22 +++ implementations/javascript/src/text.ts | 2 +- implementations/javascript/src/values.ts | 137 ++++++++++-------- implementations/javascript/test/bytes.test.ts | 128 ++++++++-------- implementations/javascript/test/codec.test.ts | 8 +- 6 files changed, 197 insertions(+), 143 deletions(-) diff --git a/implementations/javascript/src/codec.ts b/implementations/javascript/src/codec.ts index c978a8b..365c937 100644 --- a/implementations/javascript/src/codec.ts +++ b/implementations/javascript/src/codec.ts @@ -12,27 +12,30 @@ import { Tag } from './constants'; import { PreserveOn } from './symbols'; export type ErrorType = 'DecodeError' | 'EncodeError' | 'ShortPacket'; - -export function isCodecError(e: any, t?: ErrorType): e is PreservesCodecError { - return typeof e === 'object' && e !== null && - '_codecErrorType' in e && - (!t || e._codecErrorType() === t); -} - -export const isDecodeError = (e: any): e is DecodeError => isCodecError(e, 'DecodeError'); -export const isEncodeError = (e: any): e is EncodeError => isCodecError(e, 'EncodeError'); -export const isShortPacket = (e: any): e is ShortPacket => isCodecError(e, 'ShortPacket'); +export const ErrorType = Symbol.for('ErrorType'); export abstract class PreservesCodecError { - abstract _codecErrorType(): ErrorType; + abstract get [ErrorType](): ErrorType; + + static isCodecError(e: any, t: ErrorType): e is PreservesCodecError { + return (e?.[ErrorType] === t); + } } export class DecodeError extends Error { - _codecErrorType(): ErrorType { return 'DecodeError' } + get [ErrorType](): ErrorType { return 'DecodeError' } + + static isDecodeError(e: any): e is DecodeError { + return PreservesCodecError.isCodecError(e, 'DecodeError'); + } } export class EncodeError extends Error { - _codecErrorType(): ErrorType { return 'EncodeError' } + get [ErrorType](): ErrorType { return 'EncodeError' } + + static isEncodeError(e: any): e is EncodeError { + return PreservesCodecError.isCodecError(e, 'EncodeError'); + } readonly irritant: any; @@ -43,7 +46,11 @@ export class EncodeError extends Error { } export class ShortPacket extends DecodeError { - _codecErrorType(): ErrorType { return 'ShortPacket' } + get [ErrorType](): ErrorType { return 'ShortPacket' } + + static isShortPacket(e: any): e is ShortPacket { + return PreservesCodecError.isCodecError(e, 'ShortPacket'); + } } export interface DecoderOptions { @@ -176,7 +183,7 @@ export class Decoder { try { return this.next(); } catch (e) { - if (e instanceof ShortPacket) { + if (ShortPacket.isShortPacket(e)) { this.index = start; return void 0; } @@ -266,7 +273,7 @@ export class Encoder { this.emitbyte(Tag.SignedInteger); this.varint(bytecount); } - const enc = (n, x) => { + const enc = (n: number, x: number) => { if (n > 0) { enc(n - 1, x >> 8); this.emitbyte(x & 255); @@ -294,7 +301,7 @@ export class Encoder { } push(v: any) { - if (typeof v === 'object' && v !== null && typeof v[PreserveOn] === 'function') { + if (typeof v?.[PreserveOn] === 'function') { v[PreserveOn](this); } else if (typeof v === 'boolean') { @@ -326,7 +333,7 @@ export class Encoder { else if (Array.isArray(v)) { this.encodevalues(Tag.Sequence, v); } - else if (typeof v === 'object' && v !== null && typeof v[Symbol.iterator] === 'function') { + else if (typeof v?.[Symbol.iterator] === 'function') { this.encodevalues(Tag.Sequence, v as Iterable); } else { diff --git a/implementations/javascript/src/flex.ts b/implementations/javascript/src/flex.ts index ed4a7c9..50b69f8 100644 --- a/implementations/javascript/src/flex.ts +++ b/implementations/javascript/src/flex.ts @@ -22,6 +22,20 @@ export type IdentitySet = Set; export const IdentityMap = Map; export const IdentitySet = Set; +export const IsMap = Symbol.for('IsMap'); +export const IsSet = Symbol.for('IsSet'); + +declare global { + interface Map { [IsMap]: boolean; } + interface MapConstructor { isMap(x: any): x is Map; } + interface Set { [IsSet]: boolean; } + interface SetConstructor { isSet(x: any): x is Set; } +} +Object.defineProperty(Map.prototype, IsMap, { get() { return true; } }); +Map.isMap = (x: any): x is Map => !!x?.[IsMap]; +Object.defineProperty(Set.prototype, IsSet, { get() { return true; } }); +Set.isSet = (x: any): x is Set => !!x?.[IsSet]; + export function _iterMap(i: Iterator | undefined, f : (s: S) => T): IterableIterator { if (!i) return void 0; const _f = (r: IteratorResult): IteratorResult => { @@ -139,6 +153,10 @@ export class FlexMap implements Map { canonicalKeys(): IterableIterator { return this.items.keys(); } + + get [IsMap](): boolean { + return true; + } } export class FlexSet implements Set { @@ -238,4 +256,8 @@ export class FlexSet implements Set { for (let k of this) if (!other.has(k)) result.add(k); return result; } + + get [IsSet](): boolean { + return true; + } } diff --git a/implementations/javascript/src/text.ts b/implementations/javascript/src/text.ts index 307b000..cd2c9d8 100644 --- a/implementations/javascript/src/text.ts +++ b/implementations/javascript/src/text.ts @@ -1,7 +1,7 @@ import { Value } from './values'; export function stringify(x: any): string { - if (typeof x === 'object' && x !== null && 'asPreservesText' in x) { + if (typeof x?.asPreservesText === 'function') { return x.asPreservesText(); } else { try { diff --git a/implementations/javascript/src/values.ts b/implementations/javascript/src/values.ts index 70f790d..7a6a040 100644 --- a/implementations/javascript/src/values.ts +++ b/implementations/javascript/src/values.ts @@ -4,7 +4,7 @@ import { PreserveOn, AsPreserve } from './symbols'; import { Tag } from './constants'; import { Encoder, encode } from './codec'; import { stringify } from './text'; -import { _iterMap, FlexMap, FlexSet, IdentityMap, IdentitySet } from './flex'; +import { _iterMap, FlexMap, FlexSet } from './flex'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -13,9 +13,9 @@ export type Value = Atom | Compound | Annotated; export type Atom = boolean | Single | Double | number | string | Bytes | symbol; export type Compound = Record | Array | Set | Dictionary; -export function isRecord(x: any): x is Record { - return Array.isArray(x) && 'label' in x; -} +export const IsPreservesRecord = Symbol.for('IsPreservesRecord'); +export const IsPreservesBytes = Symbol.for('IsPreservesBytes'); +export const IsPreservesAnnotated = Symbol.for('IsPreservesAnnotated'); export function fromJS(x: any): Value { switch (typeof x) { @@ -41,7 +41,7 @@ export function fromJS(x: any): Value { if (typeof x[AsPreserve] === 'function') { return x[AsPreserve](); } - if (isRecord(x)) { + if (Record.isRecord(x)) { return x; } if (Array.isArray(x)) { @@ -57,6 +57,7 @@ export function fromJS(x: any): Value { } export type FloatType = 'Single' | 'Double'; +export const FloatType = Symbol.for('FloatType'); export abstract class Float { readonly value: number; @@ -78,6 +79,14 @@ export abstract class Float { } abstract asPreservesText(): string; + abstract get [FloatType](): FloatType; + + static isFloat(x: any, t: FloatType): x is Float { + return (x?.[FloatType] === t); + } + + static isSingle = (x: any): x is Single => Float.isFloat(x, 'Single'); + static isDouble = (x: any): x is Double => Float.isFloat(x, 'Double'); } export class Single extends Float { @@ -92,7 +101,7 @@ export class Single extends Float { encoder.index += 4; } - _floatType(): FloatType { + get [FloatType](): FloatType { return 'Single'; } @@ -113,7 +122,7 @@ export class Double extends Float { encoder.index += 8; } - _floatType(): FloatType { + get [FloatType](): FloatType { return 'Double'; } @@ -122,22 +131,13 @@ export class Double extends Float { } } -function isFloat(x: any, t: FloatType): x is Float { - return typeof x === 'object' && x !== null && - 'value' in x && typeof x.value === 'number' && - '_floatType' in x && x._floatType() === t; -} - -export const isSingle = (x: any): x is Single => isFloat(x, 'Single'); -export const isDouble = (x: any): x is Double => isFloat(x, 'Double'); - export type BytesLike = Bytes | Uint8Array; export class Bytes { readonly _view: Uint8Array; constructor(maybeByteIterable: any = new Uint8Array()) { - if (isBytes(maybeByteIterable)) { + if (Bytes.isBytes(maybeByteIterable)) { this._view = maybeByteIterable._view; } else if (ArrayBuffer.isView(maybeByteIterable)) { this._view = new Uint8Array(maybeByteIterable.buffer, @@ -182,13 +182,13 @@ export class Bytes { static fromIO(io: string | BytesLike): string | Bytes { if (typeof io === 'string') return io; - if (isBytes(io)) return io; + if (Bytes.isBytes(io)) return io; if (io instanceof Uint8Array) return new Bytes(io); } static toIO(b : string | BytesLike): string | Uint8Array { if (typeof b === 'string') return b; - if (isBytes(b)) return b._view; + if (Bytes.isBytes(b)) return b._view; if (b instanceof Uint8Array) return b; } @@ -211,7 +211,7 @@ export class Bytes { } equals(other: any): boolean { - if (!isBytes(other)) return false; + if (!Bytes.isBytes(other)) return false; if (other.length !== this.length) return false; const va = this._view; const vb = other._view; @@ -287,6 +287,14 @@ export class Bytes { encoder.varint(this.length); encoder.emitbytes(this._view); } + + get [IsPreservesBytes](): boolean { + return true; + } + + static isBytes(x: any): x is Bytes { + return !!x?.[IsPreservesBytes]; + } } export function hexDigit(n: number): string { @@ -300,12 +308,6 @@ export function unhexDigit(asciiCode: number) { throw new Error("Invalid hex digit: " + String.fromCharCode(asciiCode)); } -export function isBytes(x: any): x is Bytes { - return typeof x === 'object' && x !== null && - '_view' in x && - x._view instanceof Uint8Array; -} - export function underlying(b: Bytes | Uint8Array): Uint8Array { return (b instanceof Uint8Array) ? b : b._view; } @@ -437,7 +439,7 @@ export class Record extends Array { } equals(other: any): boolean { - return isRecord(other) && + return Record.isRecord(other) && is(this.label, other.label) && this.every((f, i) => is(f, other.get(i))); } @@ -483,9 +485,9 @@ export class Record extends Array { } return new Record(label, fields); }; - ctor.constructorInfo = { label, arity }; - ctor.isClassOf = - (v: any): v is Record => (isRecord(v) && is(label, v.label) && v.length === arity); + 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 { @@ -505,6 +507,18 @@ export class Record extends Array { this.forEach((f) => encoder.push(f)); encoder.emitbyte(Tag.End); } + + 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 { @@ -520,8 +534,8 @@ export interface RecordConstructorInfo { } export function is(a: any, b: any): boolean { - if (isAnnotated(a)) a = a.item; - if (isAnnotated(b)) b = b.item; + if (Annotated.isAnnotated(a)) a = a.item; + if (Annotated.isAnnotated(b)) b = b.item; if (Object.is(a, b)) return true; if (typeof a !== typeof b) return false; if (typeof a === 'object') { @@ -540,18 +554,8 @@ export function hash(a: Value): number { throw new Error("shouldBeImplemented"); // TODO } -export function isClassOf(ci: RecordConstructorInfo, v: any): v is Record { - return (isRecord(v)) && is(ci.label, v.label) && (ci.arity === v.length); -} - export type DictionaryType = 'Dictionary' | 'Set'; - -export function is_Dictionary(x: any, t: DictionaryType): boolean { - return typeof x === 'object' && x !== null && x[Symbol.toStringTag] === t; -} - -export const isDictionary = (x: any): x is Dictionary => is_Dictionary(x, 'Dictionary'); -export const isSet = (x: any): x is Set => is_Dictionary(x, 'Set'); +export const DictionaryType = Symbol.for('DictionaryType'); export function _canonicalString(item: Value): string { const bs = encode(item, { canonical: true })._view; @@ -560,8 +564,16 @@ export function _canonicalString(item: Value): string { } export class Dictionary extends FlexMap { + get [DictionaryType](): DictionaryType { + return 'Dictionary'; + } + + static isDictionary(x: any): x is Dictionary { + return x?.[DictionaryType] === 'Dictionary'; + } + static fromJS(x: object): Dictionary { - if (isDictionary(x)) return x as Dictionary; + if (Dictionary.isDictionary(x)) return x as Dictionary; const d = new Dictionary(); for (let key in x) { const value = x[key]; @@ -619,6 +631,14 @@ export class Dictionary extends FlexMap { } export class Set extends FlexSet { + get [DictionaryType](): DictionaryType { + return 'Set'; + } + + static isSet(x: any): x is Set { + return x?.[DictionaryType] === 'Set'; + } + constructor(items?: Iterable) { super(_canonicalString, _iterMap(items?.[Symbol.iterator](), fromJS)); } @@ -656,13 +676,6 @@ export class Set extends FlexSet { } } -export function isAnnotated(x: any): x is Annotated { - return typeof x === 'object' && x !== null && - x.constructor.name === 'Annotated' && - 'annotations' in x && - 'item' in x; -} - export class Annotated { readonly annotations: Array; readonly item: Value; @@ -687,7 +700,7 @@ export class Annotated { } equals(other: any): boolean { - return is(this.item, isAnnotated(other) ? other.item : other); + return is(this.item, Annotated.isAnnotated(other) ? other.item : other); } hashCode(): number { @@ -702,6 +715,14 @@ export class Annotated { const anns = this.annotations.map((a) => '@' + a.asPreservesText()).join(' '); return (anns ? anns + ' ' : anns) + this.item.asPreservesText(); } + + get [IsPreservesAnnotated](): boolean { + return true; + } + + static isAnnotated(x: any): x is Annotated { + return !!x?.[IsPreservesAnnotated]; + } } export function peel(v: Value): Value { @@ -711,20 +732,20 @@ export function peel(v: Value): Value { export function strip(v: Value, depth: number = Infinity) { function step(v: Value, depth: number): Value { if (depth === 0) return v; - if (!isAnnotated(v)) return v; + if (!Annotated.isAnnotated(v)) return v; const nextDepth = depth - 1; function walk(v: Value) { return step(v, nextDepth); } - if (isRecord(v.item)) { + if (Record.isRecord(v.item)) { return new Record(step(v.item.label, depth), v.item.map(walk)); } else if (Array.isArray(v.item)) { return v.item.map(walk); - } else if (isSet(v.item)) { + } else if (Set.isSet(v.item)) { return v.item.map(walk); - } else if (isDictionary(v.item)) { + } else if (Dictionary.isDictionary(v.item)) { return v.item.mapEntries((e) => [walk(e[0]), walk(e[1])]); - } else if (isAnnotated(v.item)) { + } else if (Annotated.isAnnotated(v.item)) { throw new Error("Improper annotation structure"); } else { return v.item; @@ -734,7 +755,7 @@ export function strip(v: Value, depth: number = Infinity) { } export function annotate(v0: Value, ...anns: Value[]) { - const v = isAnnotated(v0) ? v0 : new Annotated(v0); + const v = Annotated.isAnnotated(v0) ? v0 : new Annotated(v0); anns.forEach((a) => v.annotations.push(a)); return v; } diff --git a/implementations/javascript/test/bytes.test.ts b/implementations/javascript/test/bytes.test.ts index dd856e6..65c3cb2 100644 --- a/implementations/javascript/test/bytes.test.ts +++ b/implementations/javascript/test/bytes.test.ts @@ -13,68 +13,70 @@ describe('immutable byte arrays', () => { expect(bs.every((b) => b !== 50)).toBe(true); expect(!(bs.every((b) => b !== 20))).toBe(true); }); - // it('should implement find', () => { - // assert.strictEqual(bs.find((b) => b > 20), 30); - // assert.strictEqual(bs.find((b) => b > 50), void 0); - // }); - // it('should implement findIndex', () => { - // assert.strictEqual(bs.findIndex((b) => b > 20), 2); - // assert.strictEqual(bs.findIndex((b) => b > 50), -1); - // }); - // it('should implement forEach', () => { - // const vs = []; - // bs.forEach((b) => vs.push(b)); - // assert(is(fromJS(vs), fromJS([10, 20, 30, 40]))); - // }); - // it('should implement includes', () => { - // assert(bs.includes(20)); - // assert(!bs.includes(50)); - // }); - // it('should implement indexOf', () => { - // assert.strictEqual(bs.indexOf(20), 1); - // assert.strictEqual(bs.indexOf(50), -1); - // }); - // it('should implement join', () => assert.strictEqual(bs.join('-'), '10-20-30-40')); - // it('should implement keys', () => { - // assert(is(fromJS(Array.from(bs.keys())), fromJS([0,1,2,3]))); - // }); - // it('should implement values', () => { - // assert(is(fromJS(Array.from(bs.values())), fromJS([10,20,30,40]))); - // }); - // it('should implement filter', () => { - // assert(is(bs.filter((b) => b !== 30), Bytes.of(10,20,40))); - // }); - // it('should implement slice', () => { - // const vs = bs.slice(2); - // assert(!Object.is(vs._view.buffer, bs._view.buffer)); - // assert.strictEqual(vs._view.buffer.byteLength, 2); - // assert.strictEqual(vs.get(0), 30); - // assert.strictEqual(vs.get(1), 40); - // assert.strictEqual(vs.size, 2); - // }); - // it('should implement subarray', () => { - // const vs = bs.subarray(2); - // assert(Object.is(vs._view.buffer, bs._view.buffer)); - // assert.strictEqual(vs._view.buffer.byteLength, 4); - // assert.strictEqual(vs.get(0), 30); - // assert.strictEqual(vs.get(1), 40); - // assert.strictEqual(vs.size, 2); - // }); - // it('should implement reverse', () => { - // const vs = bs.reverse(); - // assert(!Object.is(vs._view.buffer, bs._view.buffer)); - // assert.strictEqual(bs.get(0), 10); - // assert.strictEqual(bs.get(3), 40); - // assert.strictEqual(vs.get(0), 40); - // assert.strictEqual(vs.get(3), 10); - // }); - // it('should implement sort', () => { - // const vs = bs.reverse().sort(); - // assert(!Object.is(vs._view.buffer, bs._view.buffer)); - // assert.strictEqual(bs.get(0), 10); - // assert.strictEqual(bs.get(3), 40); - // assert.strictEqual(vs.get(0), 10); - // assert.strictEqual(vs.get(3), 40); - // }); + it('should implement find', () => { + expect(bs.find((b) => b > 20)).toBe(30); + expect(bs.find((b) => b > 50)).toBe(void 0); + }); + it('should implement findIndex', () => { + expect(bs.findIndex((b) => b > 20)).toBe(2); + expect(bs.findIndex((b) => b > 50)).toBe(-1); + }); + it('should implement forEach', () => { + const vs = []; + bs.forEach((b) => vs.push(b)); + expect(fromJS(vs)).is(fromJS([10, 20, 30, 40])); + }); + it('should implement includes', () => { + expect(bs.includes(20)).toBe(true); + expect(!bs.includes(50)).toBe(true); + }); + it('should implement indexOf', () => { + expect(bs.indexOf(20)).toBe(1); + expect(bs.indexOf(50)).toBe(-1); + }); + it('should implement join', () => { + expect(bs.join('-')).toBe('10-20-30-40'); + }); + it('should implement keys', () => { + expect(fromJS(Array.from(bs.keys()))).is(fromJS([0,1,2,3])); + }); + it('should implement values', () => { + expect(fromJS(Array.from(bs.values()))).is(fromJS([10,20,30,40])); + }); + it('should implement filter', () => { + expect(bs.filter((b) => b !== 30)).is(Bytes.of(10,20,40)); + }); + it('should implement slice', () => { + const vs = bs.slice(2); + expect(Object.is(vs._view.buffer, bs._view.buffer)).toBe(false); + expect(vs._view.buffer.byteLength).toBe(2); + expect(vs.get(0)).toBe(30); + expect(vs.get(1)).toBe(40); + expect(vs.length).toBe(2); + }); + it('should implement subarray', () => { + const vs = bs.subarray(2); + expect(Object.is(vs._view.buffer, bs._view.buffer)).toBe(true); + expect(vs._view.buffer.byteLength).toBe(4); + expect(vs.get(0)).toBe(30); + expect(vs.get(1)).toBe(40); + expect(vs.length).toBe(2); + }); + it('should implement reverse', () => { + const vs = bs.reverse(); + expect(Object.is(vs._view.buffer, bs._view.buffer)).toBe(false); + expect(bs.get(0)).toBe(10); + expect(bs.get(3)).toBe(40); + expect(vs.get(0)).toBe(40); + expect(vs.get(3)).toBe(10); + }); + it('should implement sort', () => { + const vs = bs.reverse().sort(); + expect(Object.is(vs._view.buffer, bs._view.buffer)).toBe(false); + expect(bs.get(0)).toBe(10); + expect(bs.get(3)).toBe(40); + expect(vs.get(0)).toBe(10); + expect(vs.get(3)).toBe(40); + }); }); }); diff --git a/implementations/javascript/test/codec.test.ts b/implementations/javascript/test/codec.test.ts index a132a79..a5f3dc7 100644 --- a/implementations/javascript/test/codec.test.ts +++ b/implementations/javascript/test/codec.test.ts @@ -2,7 +2,7 @@ import { Value, Dictionary, decode, decodeWithAnnotations, encodeWithAnnotations, - isDecodeError, isShortPacket, + DecodeError, ShortPacket, Bytes, Record, annotate, strip, peel, @@ -150,7 +150,9 @@ describe('common test suite', () => { describe(tName, () => { it('should fail with DecodeError', () => { expect(() => D(strip(t[0]) as Bytes)) - .toThrowFilter(e => isDecodeError(e) && !isShortPacket(e)); + .toThrowFilter(e => + DecodeError.isDecodeError(e) && + !ShortPacket.isShortPacket(e)); }); }); break; @@ -159,7 +161,7 @@ describe('common test suite', () => { describe(tName, () => { it('should fail with ShortPacket', () => { expect(() => D(strip(t[0]) as Bytes)) - .toThrowFilter(e => isShortPacket(e)); + .toThrowFilter(e => ShortPacket.isShortPacket(e)); }); }); break;