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
10 changes: 10 additions & 0 deletions .changeset/ci-and-testing-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rineex/ddd': patch
---

### CI and Testing Improvements

- Added a new "Type Tests" job that runs `pnpm run test:types` in the CI
workflow.
- Introduced a new script "test:types" and turbo task with inputs for
`src/**/*.test-d.ts` for type testing.
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ jobs:
- name: Run Tests
run: pnpm run test

type-tests:
name: Type Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Tools
uses: ./.github/setup
- name: Run Type Tests
run: pnpm run test:types

build:
name: Build
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"test": "turbo run test",
"test:types": "turbo run test:types",
"test:ci": "turbo run test build",
"check-types": "turbo run check-types",
"prepublish": "pnpm run build",
Expand Down
3 changes: 3 additions & 0 deletions packages/ddd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"test": "vitest run",
"test:types": "tsd -f 'src/domain/types/__tests__/*.test-d.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"check-types": "tsc --noEmit",
Expand All @@ -23,8 +24,10 @@
"@rineex/typescript-config": "workspace:*",
"@types/node": "24.10.4",
"@vitest/ui": "4.0.16",
"expect-type": "1.3.0",
"fast-deep-equal": "3.1.3",
"isbot": "5.1.32",
"tsd": "0.33.0",
"tslib": "2.8.1",
"tsup": "8.5.1",
"typescript": "5.9.3",
Expand Down
13 changes: 2 additions & 11 deletions packages/ddd/src/domain/entities/entity.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { deepFreeze } from '@/utils';

import { DeepImmutable } from '../types/deep-immutable.type';
import { EntityId } from '../types';

// export type Immutable<T> = {
// readonly [K in keyof T]: Immutable<T[K]>;
// };

export type Immutable<T> = T extends (...args: any[]) => any
? T
: T extends Date
? T
: T extends Map<infer K, infer V>
? ReadonlyMap<Immutable<K>, Immutable<V>>
: T extends Set<infer U>
? ReadonlySet<Immutable<U>>
: T extends object
? { readonly [K in keyof T]: Immutable<T[K]> }
: T;
export type Immutable<T> = DeepImmutable<T>;

/**
* Configuration for the base Entity constructor.
Expand Down
92 changes: 92 additions & 0 deletions packages/ddd/src/domain/types/__tests__/deep-immutable.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* eslint-disable vitest/require-hook */
import { expectAssignable, expectType } from 'tsd';

import type { DeepImmutable } from '../deep-immutable.type';

// Helper to get a value of type DeepImmutable<T> without runtime value (for type assertions only)
function imm<T>(): DeepImmutable<T> {
return undefined as unknown as DeepImmutable<T>;
}

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

// --- Functions are preserved ---
expectType<(x: number) => string>(imm<(x: number) => string>());
expectType<() => void>(imm<() => void>());

// --- Date is preserved ---
expectType<Date>(imm<Date>());

// --- Promise: deep readonly of resolved type ---
expectAssignable<Promise<{ readonly id: number }>>(
imm<Promise<{ id: number }>>(),
);
expectAssignable<Promise<readonly number[]>>(imm<Promise<number[]>>());

// --- Map → ReadonlyMap with deep immutable keys & values ---
expectType<ReadonlyMap<string, number>>(imm<Map<string, number>>());
expectAssignable<ReadonlyMap<readonly string[], { readonly x: number }>>(
imm<Map<string[], { x: number }>>(),
);

// --- Set → ReadonlySet with deep immutable elements ---
expectType<ReadonlySet<number>>(imm<Set<number>>());
expectType<ReadonlySet<readonly string[]>>(imm<Set<string[]>>());

// --- Array → readonly array with deep immutable elements ---
expectType<readonly number[]>(imm<number[]>());
expectType<readonly (readonly string[])[]>(imm<string[][]>());

// --- Plain objects: recursively readonly (not class instances) ---
expectAssignable<{ readonly a: number; readonly b: string }>(
imm<{ a: number; b: string }>(),
);
expectAssignable<{
readonly id: number;
readonly nested: { readonly name: string };
}>(imm<{ id: number; nested: { name: string } }>());

// --- Class instances are preserved (not recursively made readonly) ---
class Entity {
constructor(public id: string) {}
}
expectType<Entity>(imm<Entity>());
expectAssignable<Entity>(imm<Entity>());

// --- After immutability, value is still typed as instance of the class ---
class AggregateRoot {
constructor(
public readonly id: string,
public version: number,
) {}
doSomething(): void {}
}
// DeepImmutable<AggregateRoot> must still be AggregateRoot (instance of it)
expectType<AggregateRoot>(imm<AggregateRoot>());
// Type-level "instanceof": immutable value is assignable where class is required
function acceptInstanceOfAggregateRoot(_instance: AggregateRoot): void {}
acceptInstanceOfAggregateRoot(imm<AggregateRoot>());

// --- Nested structures ---
type Nested = {
arr: number[];
map: Map<string, { value: number }>;
set: Set<number[]>;
};
expectAssignable<{
readonly arr: readonly number[];
readonly map: ReadonlyMap<string, { readonly value: number }>;
readonly set: ReadonlySet<readonly number[]>;
}>(imm<Nested>());

// --- Edge: empty object ---
expectType<{}>(imm<{}>());

// --- Edge: tuple is treated as array → readonly (number | string)[] ---
expectType<readonly (number | string)[]>(imm<[number, string]>());
32 changes: 32 additions & 0 deletions packages/ddd/src/domain/types/deep-immutable.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* DeepImmutable<T> recursively marks T as immutable.
* - Preserves class instances
* - Converts Array, Map, Set to readonly
* - Preserves functions and Date
* - Preserves Promise types
*/
export type DeepImmutable<T> =
// Functions are preserved
T extends (...args: any[]) => any
? T
: // Date is preserved
T extends Date
? T
: // Promise: deep readonly of the resolved type
T extends Promise<infer U>
? Promise<DeepImmutable<U>>
: // Map: converted to ReadonlyMap with deep immutable keys & values
T extends Map<infer K, infer V>
? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>>
: // Set: converted to ReadonlySet with deep immutable elements
T extends Set<infer U>
? ReadonlySet<DeepImmutable<U>>
: // Array: converted to readonly array with deep immutable elements
T extends (infer U)[]
? readonly DeepImmutable<U>[]
: // Plain objects (not class instances) are recursively made readonly
T extends object
? T extends { constructor: Function }
? T // Preserve class instances
: { readonly [K in keyof T]: DeepImmutable<T[K]> }
: T; // primitives remain as-is
Loading
Loading