From b17ad1b745f54e2ce4c0edc5aa563ac1d6e8cca8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Sep 2024 12:57:37 +0200 Subject: [PATCH] frontend-internal: add support for undefined version for opaque types Signed-off-by: Patrik Oldsberg --- .../src/wiring/OpaqueType.test.ts | 200 ++++++++++++++++++ .../src/wiring/OpaqueType.ts | 27 ++- 2 files changed, 218 insertions(+), 9 deletions(-) diff --git a/packages/frontend-internal/src/wiring/OpaqueType.test.ts b/packages/frontend-internal/src/wiring/OpaqueType.test.ts index 052e9dc041..8e0ab0a3c8 100644 --- a/packages/frontend-internal/src/wiring/OpaqueType.test.ts +++ b/packages/frontend-internal/src/wiring/OpaqueType.test.ts @@ -262,4 +262,204 @@ describe('OpaqueType', () => { 'foo', ); }); + + it('should support undefined version for backwards compatibility', () => { + type MyType = { + $$type: 'my-type'; + }; + + const OpaqueMyType = OpaqueType.create<{ + public: MyType; + versions: { + version: undefined; + foo: string; + }; + }>({ + type: 'my-type', + versions: [undefined], + }); + + OpaqueMyType.create({ + // @ts-expect-error - wrong type + $$type: 'wrong-type', + foo: 'bar', + }); + + OpaqueMyType.create({ + $$type: 'my-type', + // @ts-expect-error - unsupported version + version: 'v1', + foo: 'bar', + }); + + // @ts-expect-error - missing internal field + OpaqueMyType.create({ + $$type: 'my-type', + version: undefined, + }); + + OpaqueMyType.create({ + $$type: 'my-type', + version: undefined, + // @ts-expect-error - invalid internal field + foo: 3, + }); + + const myInstance = OpaqueMyType.create({ + $$type: 'my-type', + version: undefined, + foo: 'bar', + }); + + expect(myInstance.$$type).toBe('my-type'); + // @ts-expect-error - version field not accessible + expect(myInstance.version).toBe(undefined); + // @ts-expect-error - internal field not accessible + expect(myInstance.foo).toBe('bar'); + + expect(OpaqueMyType.isInternal(myInstance)).toBe(true); + + const myInternal = OpaqueMyType.toInternal(myInstance); + expect(myInternal).toBe(myInstance); + // All fields accessible + expect(myInternal.$$type).toBe('my-type'); + expect(myInternal.version).toBe(undefined); + expect(myInternal.foo).toBe('bar'); + + expect(OpaqueMyType.isVersion(undefined, myInstance)).toBe(true); + expect(OpaqueMyType.isVersion('v1' as any, myInstance)).toBe(false); + + expect(OpaqueMyType.isInternal('hello')).toBe(false); + expect(OpaqueMyType.isVersion(undefined, 'hello')).toBe(false); + }); + + it('should support undefined version mixed with defined versions', () => { + type MyType = { + $$type: 'my-type'; + }; + + const OpaqueMyType = OpaqueType.create<{ + public: MyType; + versions: + | { + version: 'v1'; + foo: string; + } + | { + version: undefined; + bar: string; + }; + }>({ + type: 'my-type', + versions: ['v1', undefined], + }); + + OpaqueMyType.create({ + // @ts-expect-error - wrong type + $$type: 'wrong-type', + version: 'v1', + foo: 'bar', + }); + + OpaqueMyType.create({ + $$type: 'my-type', + // @ts-expect-error - unsupported version + version: 'v3', + foo: 'bar', + }); + + // @ts-expect-error - missing version + OpaqueMyType.create({ + $$type: 'my-type', + foo: 'bar', + }); + + // @ts-expect-error - missing internal field + OpaqueMyType.create({ + $$type: 'my-type', + version: 'v1', + }); + + OpaqueMyType.create({ + $$type: 'my-type', + version: 'v1', + // @ts-expect-error - invalid internal field + foo: 3, + }); + + OpaqueMyType.create({ + $$type: 'my-type', + version: undefined, + // @ts-expect-error - version mismatch + foo: 'bar', + }); + + OpaqueMyType.create({ + $$type: 'my-type', + version: 'v1', + // @ts-expect-error - version mismatch + bar: 'foo', + }); + + const myInstanceV1 = OpaqueMyType.create({ + $$type: 'my-type', + version: 'v1', + foo: 'bar', + }); + + const myInstanceV2 = OpaqueMyType.create({ + $$type: 'my-type', + version: undefined, + bar: 'foo', + }); + + expect(myInstanceV1.$$type).toBe('my-type'); + // @ts-expect-error - version field not accessible + expect(myInstanceV1.version).toBe('v1'); + // @ts-expect-error - internal field not accessible + expect(myInstanceV1.foo).toBe('bar'); + + expect(myInstanceV2.$$type).toBe('my-type'); + // @ts-expect-error - version field not accessible + expect(myInstanceV2.version).toBe(undefined); + // @ts-expect-error - internal field not accessible + expect(myInstanceV2.bar).toBe('foo'); + + expect(OpaqueMyType.isInternal(myInstanceV1)).toBe(true); + expect(OpaqueMyType.isInternal(myInstanceV2)).toBe(true); + + const myInternalV1 = OpaqueMyType.toInternal(myInstanceV1); + expect(myInternalV1).toBe(myInstanceV1); + // All fields accessible + expect(myInternalV1.$$type).toBe('my-type'); + expect(myInternalV1.version).toBe('v1'); + // @ts-expect-error - version has not been narrowed down + expect(myInternalV1.foo).toBe('bar'); + + const myInternalV2 = OpaqueMyType.toInternal(myInstanceV2); + expect(myInternalV2).toBe(myInstanceV2); + // All fields accessible + expect(myInternalV2.$$type).toBe('my-type'); + expect(myInternalV2.version).toBe(undefined); + // @ts-expect-error - version has not been narrowed down + expect(myInternalV2.bar).toBe('foo'); + + // Narrowing the version allows access to internal fields + expect(myInternalV1.version === 'v1' && myInternalV1.foo).toBe('bar'); + expect(myInternalV2.version === undefined && myInternalV2.bar).toBe('foo'); + + expect(OpaqueMyType.isVersion('v1', myInstanceV1)).toBe(true); + expect(OpaqueMyType.isVersion(undefined, myInstanceV1)).toBe(false); + + expect(OpaqueMyType.isVersion('v1', myInstanceV2)).toBe(false); + expect(OpaqueMyType.isVersion(undefined, myInstanceV2)).toBe(true); + + // Narrowing the version allows access to internal fields + expect(OpaqueMyType.isVersion('v1', myInstanceV1) && myInstanceV1.foo).toBe( + 'bar', + ); + expect( + OpaqueMyType.isVersion(undefined, myInstanceV2) && myInstanceV2.bar, + ).toBe('foo'); + }); }); diff --git a/packages/frontend-internal/src/wiring/OpaqueType.ts b/packages/frontend-internal/src/wiring/OpaqueType.ts index 868bca3315..332843f6cc 100644 --- a/packages/frontend-internal/src/wiring/OpaqueType.ts +++ b/packages/frontend-internal/src/wiring/OpaqueType.ts @@ -28,7 +28,7 @@ export class OpaqueType< T extends { public: { $$type: string }; - versions: { version: string }; + versions: { version?: string } & Object; }, > { /** @@ -41,7 +41,7 @@ export class OpaqueType< static create< T extends { public: { $$type: string }; - versions: { version: string }; + versions: { version?: string } & Object; }, >(options: { type: T['public']['$$type']; @@ -51,9 +51,9 @@ export class OpaqueType< } #type: string; - #versions: Set; + #versions: Set; - private constructor(type: string, versions: Set) { + private constructor(type: string, versions: Set) { this.#type = type; this.#versions = versions; } @@ -83,7 +83,7 @@ export class OpaqueType< */ toInternal(value: unknown): T['public'] & T['versions'] { if (!this.#isThisType(value)) { - throw new Error( + throw new TypeError( `Invalid opaque type, expected '${ this.#type }', but got '${this.#stringifyUnknown(value)}'`, @@ -133,11 +133,20 @@ export class OpaqueType< return value as unknown as TBase; } - #throwIfInvalidVersion(version: string) { + #throwIfInvalidVersion(version: string | undefined) { if (!this.#versions.has(version)) { - const versionsStr = Array.from(this.#versions).join("', '"); - throw new Error( - `Invalid opaque type instance, bad version '${version}', expected one of '${versionsStr}'`, + const expected = []; + if (this.#versions.has(undefined)) { + expected.push('undefined'); + } + const versions = Array.from(this.#versions).filter(Boolean); + if (versions.length > 0) { + expected.push(`one of ['${versions.join("', '")}']`); + } + throw new TypeError( + `Invalid opaque type instance, got version '${version}', expected ${expected.join( + ' or ', + )}`, ); } }