Repository: mikro-orm/mikro-orm (TypeScript)

Issue:
Serialization with forceObject: true does not apply primary key custom type
### Describe the bug

Serialization using `forceObject: true` doesn't apply custom type on foreign key. 


### Reproduction

```ts
import { MikroORM, serialize, Entity, ManyToOne, Platform, PrimaryKey, Property, Ref, Type } from '@mikro-orm/sqlite';

export class IDStoredAsInteger extends Type<string, number> {

  convertToDatabaseValue(value: string): number {
    return parseInt(value);
  }

  convertToJSValue(value: number): string {
    return `${value}`;
  }

  getColumnType() {
    return `integer`;
  }

}

@Entity()
class User {

  @PrimaryKey({ type: IDStoredAsInteger })
  id!: string;

  @Property({ unique: true })
  email!: string;

  @ManyToOne(() => User, { nullable: true })
  bestFriend?: Ref<User>;

}

let orm: MikroORM;

beforeAll(async () => {
  orm = await MikroORM.init({
    dbName: ':memory:',
    entities: [User],
  });
  await orm.schema.createSchema();
});

afterAll(async () => {
  await orm.close(true);
});

it('should apply custom type when serializing primary key of non-populated relation', async () => {
  const totoUser = orm.em.create(User, {
    email: 'toto@test.com',
  });
  orm.em.create(User, {
    email: 'yolo@test.com',
    bestFriend: totoUser,
  });
  await orm.em.flush();

  const u1 = await orm.em.fork().findOneOrFail(User, { email: 'yolo@test.com' });
  const dto1 = serialize(u1);
  expect(typeof dto1.id).toBe('string');
  expect(dto1.bestFriend).toBeDefined();
  expect(typeof dto1.bestFriend).toBe('string');

  const u2 = await orm.em.fork().findOneOrFail(User, { email: 'yolo@test.com' }, { populate: ['bestFriend'] });
  const dto2 = serialize(u2);
  expect(typeof dto2.id).toBe('string');
  expect(dto2.bestFriend).toBeDefined();
  expect(typeof dto2.bestFriend).toBe('string');
});
```

### What driver are you using?

@mikro-orm/postgresql

### MikroORM version

6.3

### Node.js version

v20.11.1

### Operating system

Macos

### Validations

- [X] Read the [Contributing Guidelines](https://github.com/mikro-orm/mikro-orm/blob/master/CONTRIBUTING.md).
- [X] Read the [docs](https://mikro-orm.io/docs).
- [X] Check that there isn't [already an issue](https://github.com/mikro-orm/mikro-orm/issues) that reports the same bug to avoid creating a duplicate.
- [X] Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/mikro-orm/mikro-orm/discussions) or join our [Discord](https://discord.gg/w8bjxFHS7X).
- [X] The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.



Fix (patch):
diff --git a/packages/core/src/serialization/EntitySerializer.ts b/packages/core/src/serialization/EntitySerializer.ts
index 936d83531a36..8349abd8cf88 100644
--- a/packages/core/src/serialization/EntitySerializer.ts
+++ b/packages/core/src/serialization/EntitySerializer.ts
@@ -8,6 +8,7 @@ import type {
   EntityDTOProp,
   EntityKey,
   EntityMetadata,
+  EntityProperty,
   EntityValue,
   FromEntityType,
   IPrimaryKey,
@@ -157,7 +158,7 @@ export class EntitySerializer {
     const parts = prop.split('.');
     prop = parts[0] as EntityKey<T>;
     const wrapped = helper(entity);
-    const property = wrapped.__meta.properties[prop];
+    const property = wrapped.__meta.properties[prop] ?? { name: prop };
     const serializer = property?.serializer;
     const value = entity[prop];
 
@@ -177,11 +178,11 @@ export class EntitySerializer {
     }
 
     if (Utils.isCollection(value)) {
-      return this.processCollection(prop, entity, options);
+      return this.processCollection(property, entity, options);
     }
 
     if (Utils.isEntity(value, true)) {
-      return this.processEntity(prop, entity, wrapped.__platform, options);
+      return this.processEntity(property, entity, wrapped.__platform, options);
     }
 
     if (Utils.isScalarReference(value)) {
@@ -216,20 +217,24 @@ export class EntitySerializer {
     } as SerializeOptions<U>;
   }
 
-  private static processEntity<T extends object>(prop: EntityKey<T>, entity: T, platform: Platform, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
-    const child = Reference.unwrapReference(entity[prop] as T);
+  private static processEntity<T extends object>(prop: EntityProperty<T>, entity: T, platform: Platform, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
+    const child = Reference.unwrapReference(entity[prop.name] as T);
     const wrapped = helper(child);
-    const populated = isPopulated(prop, options) && wrapped.isInitialized();
+    const populated = isPopulated(prop.name, options) && wrapped.isInitialized();
     const expand = populated || !wrapped.__managed;
     const meta = wrapped.__meta;
-    const childOptions = this.extractChildOptions(options, prop) as Dictionary;
+    const childOptions = this.extractChildOptions(options, prop.name) as Dictionary;
     const visible = meta.primaryKeys.filter(prop => isVisible(meta, prop, childOptions));
 
     if (expand) {
       return this.serialize(child, childOptions) as EntityValue<T>;
     }
 
-    const pk = wrapped.getPrimaryKey(true)!;
+    let pk = wrapped.getPrimaryKey()!;
+
+    if (prop.customType) {
+      pk = prop.customType.toJSON(pk, wrapped.__platform);
+    }
 
     if (options.forceObject || wrapped.__config.get('serialization').forceObject) {
       return Utils.primaryKeyToObject(meta, pk, visible) as EntityValue<T>;
@@ -248,26 +253,32 @@ export class EntitySerializer {
     return platform.normalizePrimaryKey(pk as IPrimaryKey) as EntityValue<T>;
   }
 
-  private static processCollection<T extends object>(prop: EntityKey<T>, entity: T, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
-    const col = entity[prop] as unknown as Collection<T>;
+  private static processCollection<T extends object>(prop: EntityProperty<T>, entity: T, options: SerializeOptions<T, any, any>): EntityValue<T> | undefined {
+    const col = entity[prop.name] as Collection<T>;
 
     if (!col.isInitialized()) {
       return undefined;
     }
 
     return col.getItems(false).map(item => {
-      const populated = isPopulated(prop, options);
+      const populated = isPopulated(prop.name, options);
       const wrapped = helper(item);
 
       if (populated || !wrapped.__managed) {
-        return this.serialize(item, this.extractChildOptions(options, prop));
+        return this.serialize(item, this.extractChildOptions(options, prop.name));
+      }
+
+      let pk = wrapped.getPrimaryKey()!;
+
+      if (prop.customType) {
+        pk = prop.customType.toJSON(pk, wrapped.__platform);
       }
 
       if (options.forceObject || wrapped.__config.get('serialization').forceObject) {
-        return Utils.primaryKeyToObject(wrapped.__meta, wrapped.getPrimaryKey(true)!) as EntityValue<T>;
+        return Utils.primaryKeyToObject(wrapped.__meta, pk) as EntityValue<T>;
       }
 
-      return helper(item).getPrimaryKey();
+      return pk;
     }) as unknown as EntityValue<T>;
   }
 
diff --git a/packages/core/src/serialization/EntityTransformer.ts b/packages/core/src/serialization/EntityTransformer.ts
index 94b26df5e716..509afa76c6ce 100644
--- a/packages/core/src/serialization/EntityTransformer.ts
+++ b/packages/core/src/serialization/EntityTransformer.ts
@@ -1,5 +1,14 @@
 import type { Collection } from '../entity/Collection';
-import type { AnyEntity, Dictionary, EntityDTO, EntityKey, EntityMetadata, EntityValue, IPrimaryKey } from '../typings';
+import type {
+  AnyEntity,
+  Dictionary,
+  EntityDTO,
+  EntityKey,
+  EntityMetadata,
+  EntityProperty,
+  EntityValue,
+  IPrimaryKey,
+} from '../typings';
 import { helper, wrap } from '../entity/wrap';
 import type { Platform } from '../platforms';
 import { Utils } from '../utils/Utils';
@@ -145,7 +154,7 @@ export class EntityTransformer {
 
   private static processProperty<Entity extends object>(prop: EntityKey<Entity>, entity: Entity, raw: boolean, populated: boolean): EntityValue<Entity> | undefined {
     const wrapped = helper(entity);
-    const property = wrapped.__meta.properties[prop];
+    const property = wrapped.__meta.properties[prop] ?? { name: prop };
     const serializer = property?.serializer;
     const value = entity[prop];
 
@@ -164,11 +173,11 @@ export class EntityTransformer {
     }
 
     if (Utils.isCollection(value)) {
-      return EntityTransformer.processCollection(prop, entity, raw, populated);
+      return EntityTransformer.processCollection(property, entity, raw, populated);
     }
 
     if (Utils.isEntity(value, true)) {
-      return EntityTransformer.processEntity(prop, entity, wrapped.__platform, raw, populated);
+      return EntityTransformer.processEntity(property, entity, wrapped.__platform, raw, populated) as EntityValue<Entity>;
     }
 
     if (Utils.isScalarReference(value)) {
@@ -200,8 +209,8 @@ export class EntityTransformer {
     return value;
   }
 
-  private static processEntity<Entity extends object>(prop: keyof Entity, entity: Entity, platform: Platform, raw: boolean, populated: boolean): EntityValue<Entity> | undefined {
-    const child = entity[prop] as unknown as Entity | Reference<Entity>;
+  private static processEntity<Entity extends object>(prop: EntityProperty<Entity>, entity: Entity, platform: Platform, raw: boolean, populated: boolean): EntityValue<Entity> | undefined {
+    const child = entity[prop.name] as Entity | Reference<Entity>;
     const wrapped = helper(child as Entity);
     const meta = wrapped.__meta;
     const visible = meta.primaryKeys.filter(prop => isVisible(meta, prop));
@@ -226,7 +235,11 @@ export class EntityTransformer {
       return wrap(child).toJSON() as EntityValue<Entity>;
     }
 
-    const pk = wrapped.getPrimaryKey(true)!;
+    let pk = wrapped.getPrimaryKey()!;
+
+    if (prop.customType) {
+      pk = prop.customType.toJSON(pk, wrapped.__platform);
+    }
 
     if (wrapped.__config.get('serialization').forceObject) {
       return Utils.primaryKeyToObject(meta, pk, visible) as EntityValue<Entity>;
@@ -245,8 +258,8 @@ export class EntityTransformer {
     return platform.normalizePrimaryKey(pk as IPrimaryKey) as EntityValue<Entity>;
   }
 
-  private static processCollection<Entity extends object>(prop: keyof Entity, entity: Entity, raw: boolean, populated: boolean): EntityValue<Entity> | undefined {
-    const col = entity[prop] as Collection<AnyEntity>;
+  private static processCollection<Entity extends object>(prop: EntityProperty<Entity>, entity: Entity, raw: boolean, populated: boolean): EntityValue<Entity> | undefined {
+    const col = entity[prop.name] as Collection<AnyEntity>;
 
     if (raw && col.isInitialized(true)) {
       return col.map(item => helper(item).toPOJO()) as EntityValue<Entity>;
@@ -258,15 +271,22 @@ export class EntityTransformer {
 
     if (col.isInitialized()) {
       const wrapped = helper(entity);
+      const forceObject = wrapped.__config.get('serialization').forceObject;
 
-      if (wrapped.__config.get('serialization').forceObject) {
-        return col.map(item => {
-          const wrapped = helper(item);
-          return Utils.primaryKeyToObject(wrapped.__meta, wrapped.getPrimaryKey(true)!) as EntityValue<Entity>;
-        }) as EntityValue<Entity>;
-      }
+      return col.map(item => {
+        const wrapped = helper(item);
+        const pk = wrapped.getPrimaryKey()!;
+
+        if (prop.customType) {
+          return prop.customType.toJSON(pk, wrapped.__platform);
+        }
+
+        if (forceObject) {
+          return Utils.primaryKeyToObject(wrapped.__meta, pk) as EntityValue<Entity>;
+        }
 
-      return col.map(i => helper(i).getPrimaryKey(true)) as EntityValue<Entity>;
+        return pk;
+      }) as EntityValue<Entity>;
     }
 
     return undefined;