diff --git a/.changeset/deep-primitive-entity-tojson.md b/.changeset/deep-primitive-entity-tojson.md new file mode 100644 index 0000000..df47abf --- /dev/null +++ b/.changeset/deep-primitive-entity-tojson.md @@ -0,0 +1,12 @@ +--- +'@rineex/ddd': minor +--- + +Add `DeepPrimitive` and type-safe `Entity.toJSON()` return types. + +- `DeepPrimitive` recursively maps domain values to JSON-safe primitives + (`Date` → ISO string, `EntityId` → `value`, `ValueObject` unwrap, + arrays/objects) +- `EntityJson` is the structural shape returned by `toJSON()` +- `Entity.toJSON()` is typed as `EntityJson` instead of + `Record` diff --git a/packages/ddd/src/domain/entities/entity.ts b/packages/ddd/src/domain/entities/entity.ts index cddea9c..9af842f 100644 --- a/packages/ddd/src/domain/entities/entity.ts +++ b/packages/ddd/src/domain/entities/entity.ts @@ -1,7 +1,7 @@ import { deepFreeze } from '@/utils'; import { DeepImmutable } from '../types/deep-immutable.type'; -import { EntityId } from '../types'; +import { EntityId, EntityJson } from '../types'; // export type Immutable = { // readonly [K in keyof T]: Immutable; @@ -108,12 +108,12 @@ export abstract class Entity { /** * Converts the entity to a plain JSON-safe object with primitive values. */ - public toJSON(): Record { + public toJSON(): EntityJson { return Entity.normalize({ ...this.#props, createdAt: this.createdAt, id: this.id.value, - }) as Record; + }) as EntityJson; } /** diff --git a/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts b/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts new file mode 100644 index 0000000..76230cd --- /dev/null +++ b/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts @@ -0,0 +1,132 @@ +/* eslint-disable vitest/require-hook */ +import { expectAssignable, expectType } from 'tsd'; + +import { DomainID } from '@/domain/value-objects/domain-id.vo'; + +import type { DeepPrimitive, EntityJson } from '../deep-primitive.type'; +import type { EntityProps } from '../../entities/entity'; +import type { EntityId } from '../entity-id.type'; +import { Entity } from '../../entities/entity'; +import { ValueObject } from '../../base/vo'; + +function prim(): DeepPrimitive { + return undefined as unknown as DeepPrimitive; +} + +// --- Primitives remain as-is --- +expectType(prim()); +expectType(prim()); +expectType(prim()); +expectType(prim()); +expectType(prim()); + +// --- Non-JSON primitives become string --- +expectType(prim()); +expectType(prim<() => void>()); + +// --- Date → ISO string --- +expectType(prim()); + +// --- EntityId → primitive --- +expectType(prim()); + +// --- Arrays recurse --- +expectType(prim()); +expectType(prim()); +expectAssignable<{ name: string; age: number }[]>( + prim<{ name: string; age: number }[]>(), +); + +// --- Plain objects recurse --- +expectAssignable<{ a: number; b: string }>(prim<{ a: number; b: string }>()); +expectAssignable<{ + id: number; + nested: { name: string }; +}>(prim<{ id: number; nested: { name: string } }>()); + +// --- Date fields in objects become string --- +expectAssignable<{ createdAt: string; name: string }>( + prim<{ createdAt: Date; name: string }>(), +); + +// --- ValueObject unwraps to deep primitive of props --- +class Email extends ValueObject<{ address: string }> { + protected validate(): void {} +} +expectAssignable<{ address: string }>(prim()); + +// --- toJSON hook --- +class JsonSerializable { + toJSON(): { value: number } { + return { value: 1 }; + } +} +expectAssignable<{ value: number }>(prim()); + +// --- Map / Set → empty object --- +expectType>(prim>()); +expectType>(prim>()); + +// --- Class without toJSON → empty object --- +class PlainClass { + constructor(private readonly id: string) {} +} +expectType<{}>(prim()); + +// --- EntityJson shape --- +interface UserProps { + name: string; + email: string; +} + +type UserJson = EntityJson; +expectAssignable<{ + name: string; + email: string; + createdAt: string; + id: string; +}>(undefined as unknown as UserJson); + +// --- Entity.toJSON() returns structural type, not Record --- +class User extends Entity { + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(params: EntityProps) { + super(params); + } + + public toObject(): Record { + return {}; + } + + public validate(): void {} +} + +declare const user: User; +const json = user.toJSON(); + +expectType(json.name); +expectType(json.email); +expectType(json.createdAt); +expectType(json.id); + +expectAssignable<{ + name: string; + email: string; + createdAt: string; + id: string; +}>(json); + +// Structural keys are known — not erased to Record +function acceptUserJson(value: { + name: string; + email: string; + createdAt: string; + id: string; +}): void {} +acceptUserJson(json); + +// EntityId interface value type +interface TestId extends EntityId { + readonly value: string; +} +expectType(prim()); diff --git a/packages/ddd/src/domain/types/deep-primitive.type.ts b/packages/ddd/src/domain/types/deep-primitive.type.ts new file mode 100644 index 0000000..0e1ac55 --- /dev/null +++ b/packages/ddd/src/domain/types/deep-primitive.type.ts @@ -0,0 +1,57 @@ +import type { ValueObject } from '../base/vo'; + +import type { EntityId } from './entity-id.type'; + +type JsonPrimitive = boolean | number | string; + +type JsonIdValue = T extends string + ? string + : T extends number + ? number + : T extends boolean + ? boolean + : DeepPrimitive; + +/** + * Recursively maps T to JSON-safe primitive values. + * Mirrors {@link Entity.normalize} runtime behavior. + */ +export type DeepPrimitive = T extends null + ? null + : T extends undefined + ? undefined + : T extends JsonPrimitive + ? T + : T extends string + ? string + : T extends number + ? number + : T extends boolean + ? boolean + : T extends Date + ? string + : T extends bigint | symbol + ? string + : T extends (...args: any[]) => any + ? string + : T extends EntityId + ? JsonIdValue + : T extends ValueObject + ? DeepPrimitive

+ : T extends { toJSON: () => infer J } + ? DeepPrimitive + : T extends readonly (infer U)[] + ? DeepPrimitive[] + : T extends Map | Set + ? Record + : T extends object + ? { [K in keyof T]: DeepPrimitive } + : string; + +/** + * JSON-safe shape returned by {@link Entity.toJSON}. + */ +export type EntityJson = DeepPrimitive & { + createdAt: string; + id: JsonIdValue; +}; diff --git a/packages/ddd/src/domain/types/index.ts b/packages/ddd/src/domain/types/index.ts index c6edc32..8f3a215 100644 --- a/packages/ddd/src/domain/types/index.ts +++ b/packages/ddd/src/domain/types/index.ts @@ -1,2 +1,3 @@ +export * from './deep-primitive.type'; export * from './entity-id.type'; export * from './mapper.type';