import { expect, test, describe } from 'bun:test'; import { Tester, wrap } from './utils.ts'; import { DateTime } from 'luxon'; import { transformer } from '../src/transformers.ts'; class PrivateClass { #private: number; public: number; constructor(_private: number, _public: number) { this.#private = _private; this.public = _public; } get value() { return this.#private + this.public; } } test('ignores private properties', () => { const tester = Tester.createForAll(); const input = new PrivateClass(1, 2); // Private properties are not enumerable, so they should be just ignored. // The same goes for getters, which are also not enumerable. tester.serialize({ input }, (serialized, serializer) => { const expected = Tester.addVersionToSerialized(serializer, { input: { public: 2, }, }); expect(serialized).toStrictEqual(expected); }); }); const dateTimeTransformer = transformer({ clazz: DateTime, type: 'DateTime', serialize: value => value.toISO()!, deserialize: value => DateTime.fromISO(value, { setZone: true }), }); test('Luxon DateTime to ISO string', () => { const tester = Tester.createForAll([ dateTimeTransformer ]); const input = DateTime.utc(2026, 8, 27, 12, 34, 56); const serialized = input.toISO()!; tester.serializeDeserialize({ input }, { input: serialized, $: { input: 'DateTime' }, }); tester.serializeDeserialize(input, wrap(serialized, 'DateTime')); }); class SimpleDateTime { private constructor( readonly iso: string, ) {} static create(iso: string): SimpleDateTime { return new SimpleDateTime(iso); } } const simpleDateTimeTransformer = transformer({ clazz: SimpleDateTime, // Let's try a different name. type: 'SDT', serialize: value => value.iso, deserialize: value => SimpleDateTime.create(value), }); test('class to primitive', () => { const tester = Tester.createForAll([ simpleDateTimeTransformer ]); const input = SimpleDateTime.create('Bazinga!'); tester.serializeDeserialize({ input }, { input: 'Bazinga!', $: { input: 'SDT' }, }); tester.serializeDeserialize(input, wrap('Bazinga!', 'SDT')); }); class Point { constructor( readonly x: number, readonly y: number, readonly z?: number, ) {} } const pointTransformer = transformer({ clazz: Point, type: 'Point', // This should test that we can leverage the build-in functions to correctly handle composite indexes. serialize: (value, serializer) => serializer.serializeArray([ value.x, value.y, value.z, ]), deserialize: (value, deserializer) => { const [ x, y, z ] = deserializer.deserializeArray(value) as [ number, number, number | undefined ]; return new Point(x, y, z); }, isComposite: true, }); test('class to array', () => { const tester = Tester.createForAll([ pointTransformer ]); const input = [ new Point(6, 9), new Point(4, 2, 0), ]; tester.serializeDeserialize({ input }, { input: [ [ 6, 9, null ], [ 4, 2, 0 ], ], $: { input: { 1: 'Point', 4: 'undefined', 5: 'Point' } }, }); }); class User { constructor( readonly id: number, readonly name: string, readonly friends: User[], readonly createdAt: Date, ) {} } const userTransformer = transformer({ clazz: User, type: 'User', // This is kinda complex, so we use the built-in object functions. // We could copy the object like this: // `serializer.serializePlainObject({ id: value.id, name: value.name, friends: value.friends, createdAt: value.createdAt, responses: value.responses });` // However, since we know that the object is just a plain object without any extra properties, we might as well save some work. serialize: (value, serializer) => serializer.serializePlainObject(value), // This is the best way how to deserialize custom types while still running their constructors and supporting circular references. // The instance will be registered correctly, because it's passed as the `output` parameter to `deserializePlainObject`. // `Object.create(User.prototype)` would work without silencing TS, but it would not call the constructor which is a major red flag. // @ts-expect-error - We will fill the object later. deserialize: (value, deserializer) => deserializer.deserializePlainObject(value, new User()), // Users can befriend one another (even themselves), so they must be tracked as entities to support circular references. isEntity: true, }); describe('circular references between custom types', () => { const tester = Tester.createForAll([ userTransformer ]); test('a user who is friends with himself', () => { const alice = new User(1, 'Alice', [], new Date('2020-01-01T00:00:00.000Z')); alice.friends.push(alice); tester.serializeDeserialize({ input: alice }, { input: { id: 1, name: 'Alice', friends: [ 1 ], createdAt: '2020-01-01T00:00:00.000Z', $: { friends: { 1: 'ref' }, createdAt: 'Date' }, }, $: { input: 'User' }, }, { input: { id: 1, name: 'Alice', friends: [ 1 ], createdAt: '2020-01-01T00:00:00.000Z', $: { friends: { 1: 'ref' }, createdAt: 'Date' }, }, $: { input: [ 'User', 1 ] }, }); }); test('mutual friends form a cycle through custom types', () => { const alice = new User(1, 'Alice', [], new Date('2020-01-01T00:00:00.000Z')); const bob = new User(2, 'Bob', [ alice ], new Date('2020-02-02T00:00:00.000Z')); alice.friends.push(bob); const carol = new User(3, 'Carol', [], new Date('2020-03-03T00:00:00.000Z')); alice.friends.push(carol); tester.serializeDeserialize({ input: alice }, { input: { id: 1, name: 'Alice', friends: [ { id: 2, name: 'Bob', friends: [ 1 ], createdAt: '2020-02-02T00:00:00.000Z', $: { friends: { 1: 'ref' }, createdAt: 'Date' }, }, { createdAt: '2020-03-03T00:00:00.000Z', friends: [], id: 3, name: 'Carol', $: { createdAt: 'Date' }, } ], createdAt: '2020-01-01T00:00:00.000Z', $: { friends: { 1: 'User', 2: 'User' }, createdAt: 'Date' }, }, $: { input: 'User' }, }, { input: { id: 1, name: 'Alice', friends: [ { id: 2, name: 'Bob', friends: [ 1 ], createdAt: '2020-02-02T00:00:00.000Z', $: { friends: { 1: 'ref' }, createdAt: 'Date' }, }, { createdAt: '2020-03-03T00:00:00.000Z', friends: [], id: 3, name: 'Carol', $: { createdAt: 'Date' }, } ], createdAt: '2020-01-01T00:00:00.000Z', $: { friends: { 1: 'User', 2: 'User' }, createdAt: 'Date' }, }, $: { input: [ 'User', 1 ] }, }); }); }); class Author { constructor( readonly name: string, ) {} } const authorTransformer = transformer({ clazz: Author, type: 'Author', // Let's try direct serialization. // This is generally not recommended because it can lead to issues on so many levels. // But anything is possible if you are brave enough. serialize: value => ({ name: value.name }), // For example, this breaks references! // Even though the serialization process will set the reference, there is no way how to access it here. // So, don't do this! // The internal API might be exposed in the future if a really good use case is found, but for now, it remains closed. deserialize: value => new Author((value as { name: string }).name), }); class Comment { constructor( readonly author: Author, readonly content: string, readonly createdAt: Date, readonly responses?: Comment[], ) {} } const commentTransformer = transformer({ clazz: Comment, type: 'Comment', serialize: (value, serializer) => serializer.serializePlainObject(value), // This works but breaks references and creates an additional throwaway object. But if we don't use references (and need, for example, to run the constructor with full parameters), this is also a good choice. deserialize: (value, deserializer) => { const object = deserializer.deserializePlainObject(value) as { author: Author; content: string; createdAt: Date; responses?: Comment[]; }; return new Comment(object.author, object.content, object.createdAt, object.responses); }, }); test('recursively nested classes', () => { const tester = Tester.createForAll([ authorTransformer, commentTransformer ]); const alice = new Author('Alice'); const bob = new Author('Bob'); const charlie = new Author('Charlie'); const eve = new Author('Eve'); const input = new Comment(alice, 'Root comment', new Date('2001-01-01T00:00:00.000Z'), [ new Comment(eve, 'First reply', new Date('2002-02-02T00:00:00.000Z'), [ // No responses to this one. ]), new Comment(bob, 'Second reply', new Date('2003-03-03T00:00:00.000Z'), [ new Comment(charlie, 'Nested reply', new Date('2004-04-04T00:00:00.000Z')), // Also not here but we use undefined for that. ]), ]); // We skip the first deserialization test because it would be literally the same thing except for the one reference (which is not the focus here). tester.serializeDeserialize({ input }, { input: { author: { name: 'Alice', }, content: 'Root comment', createdAt: '2001-01-01T00:00:00.000Z', responses: [ { author: { name: 'Eve', }, content: 'First reply', createdAt: '2002-02-02T00:00:00.000Z', responses: [], $: { author: 'Author', createdAt: 'Date', }, }, { author: { name: 'Bob', }, content: 'Second reply', createdAt: '2003-03-03T00:00:00.000Z', responses: [ { author: { name: 'Charlie', }, content: 'Nested reply', createdAt: '2004-04-04T00:00:00.000Z', responses: null, $: { author: 'Author', createdAt: 'Date', responses: 'undefined', }, } ], $: { author: 'Author', createdAt: 'Date', responses: { 1: 'Comment' }, }, } ], $: { author: 'Author', createdAt: 'Date', responses: { 1: 'Comment', 2: 'Comment' }, }, }, $: { input: 'Comment', }, }); }); abstract class A { protected constructor( readonly id: number, ) {} } const aTransformer = transformer({ clazz: A, type: 'A', serialize: value => ({ id: value.id }), deserialize: value => value as unknown as A, }); class B extends A { constructor( id: number, readonly label: string, ) { super(id); } } class C extends B { constructor( id: number, label: string, readonly isActive: boolean, ) { super(id, label); } } const cTransformer = transformer({ clazz: C, type: 'C', serialize: value => ({ id: value.id, label: value.label, isActive: value.isActive, }), deserialize: value => { const object = value as { id: number, label: string, isActive: boolean }; return new C(object.id, object.label, object.isActive); }, }); class D extends C { constructor( id: number, label: string, isActive: boolean, readonly extra: string, ) { super(id, label, isActive); } } describe('complex inheritance', () => { const tester = Tester.createForAll([ aTransformer, cTransformer ]); test('prototype lookup falls back to base-class transformer', () => { const input = new B(7, 'base'); tester.serialize({ input }, (serialized, serializer) => { const expected = Tester.addVersionToSerialized(serializer, { input: { id: 7 }, $: { input: 'A' }, }); expect(serialized).toStrictEqual(expected); }); tester.forEach(serializer => { const parsed = serializer.parse(serializer.stringify(input)); // The type information is lost because of how the custom serializer is implemented. const expected = { id: 7 }; expect(parsed).toStrictEqual(expected); }); }); test('prototype lookup prefers nearest transformer in inheritance chain', () => { const input = new D(42, 'nearest', false, 'extra'); tester.serialize({ input }, (serialized, serializer) => { const expected = Tester.addVersionToSerialized(serializer, { input: { id: 42, label: 'nearest', isActive: false, }, $: { input: 'C' }, }); expect(serialized).toStrictEqual(expected); }); tester.forEach(serializer => { const parsed = serializer.parse(serializer.stringify(input)); // Again, some information is lost. const expected = new C(42, 'nearest', false); expect(parsed).toStrictEqual(expected); }); }); });