Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/deep-primitive-entity-tojson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@rineex/ddd': minor
---

Add `DeepPrimitive<T>` and type-safe `Entity.toJSON()` return types.

- `DeepPrimitive<T>` recursively maps domain values to JSON-safe primitives
(`Date` → ISO string, `EntityId` → `value`, `ValueObject` unwrap,
arrays/objects)
- `EntityJson<ID, Props>` is the structural shape returned by `toJSON()`
- `Entity.toJSON()` is typed as `EntityJson<ID, Props>` instead of
`Record<string, unknown>`
6 changes: 3 additions & 3 deletions packages/ddd/src/domain/entities/entity.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
// readonly [K in keyof T]: Immutable<T[K]>;
Expand Down Expand Up @@ -108,12 +108,12 @@ export abstract class Entity<ID extends EntityId, Props> {
/**
* Converts the entity to a plain JSON-safe object with primitive values.
*/
public toJSON(): Record<string, unknown> {
public toJSON(): EntityJson<ID, Props> {
return Entity.normalize({
...this.#props,
createdAt: this.createdAt,
id: this.id.value,
}) as Record<string, unknown>;
}) as EntityJson<ID, Props>;
}

/**
Expand Down
132 changes: 132 additions & 0 deletions packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(): DeepPrimitive<T> {
return undefined as unknown as DeepPrimitive<T>;
}

// --- Primitives remain as-is ---
expectType<string>(prim<string>());
expectType<number>(prim<number>());
expectType<boolean>(prim<boolean>());
expectType<null>(prim<null>());
expectType<undefined>(prim<undefined>());

// --- Non-JSON primitives become string ---
expectType<string>(prim<symbol>());
expectType<string>(prim<() => void>());

// --- Date → ISO string ---
expectType<string>(prim<Date>());

// --- EntityId → primitive ---
expectType<string>(prim<DomainID>());

// --- Arrays recurse ---
expectType<string[]>(prim<Date[]>());
expectType<number[]>(prim<number[]>());
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<Email>());

// --- toJSON hook ---
class JsonSerializable {
toJSON(): { value: number } {
return { value: 1 };
}
}
expectAssignable<{ value: number }>(prim<JsonSerializable>());

// --- Map / Set → empty object ---
expectType<Record<string, never>>(prim<Map<string, number>>());
expectType<Record<string, never>>(prim<Set<number>>());

// --- Class without toJSON → empty object ---
class PlainClass {
constructor(private readonly id: string) {}
}
expectType<{}>(prim<PlainClass>());

// --- EntityJson shape ---
interface UserProps {
name: string;
email: string;
}

type UserJson = EntityJson<DomainID, UserProps>;
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<DomainID, UserProps> {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(params: EntityProps<DomainID, UserProps>) {
super(params);
}

public toObject(): Record<string, unknown> {
return {};
}

public validate(): void {}
}

declare const user: User;
const json = user.toJSON();

expectType<string>(json.name);
expectType<string>(json.email);
expectType<string>(json.createdAt);
expectType<string>(json.id);

expectAssignable<{
name: string;
email: string;
createdAt: string;
id: string;
}>(json);

// Structural keys are known — not erased to Record<string, unknown>
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<string>(prim<TestId>());
57 changes: 57 additions & 0 deletions packages/ddd/src/domain/types/deep-primitive.type.ts
Original file line number Diff line number Diff line change
@@ -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> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: DeepPrimitive<T>;

/**
* Recursively maps T to JSON-safe primitive values.
* Mirrors {@link Entity.normalize} runtime behavior.
*/
export type DeepPrimitive<T> = 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['value']>
: T extends ValueObject<infer P>
? DeepPrimitive<P>
: T extends { toJSON: () => infer J }
? DeepPrimitive<J>
: T extends readonly (infer U)[]
? DeepPrimitive<U>[]
: T extends Map<any, any> | Set<any>
? Record<string, never>
: T extends object
? { [K in keyof T]: DeepPrimitive<T[K]> }
: string;

/**
* JSON-safe shape returned by {@link Entity.toJSON}.
*/
export type EntityJson<ID extends EntityId, Props> = DeepPrimitive<Props> & {
createdAt: string;
id: JsonIdValue<ID['value']>;
};
1 change: 1 addition & 0 deletions packages/ddd/src/domain/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './deep-primitive.type';
export * from './entity-id.type';
export * from './mapper.type';
Loading