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
11 changes: 11 additions & 0 deletions .changeset/result-ok-void-overload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@rineex/ddd': minor
---

Add `Result.ok()` void overload for command use cases that succeed without a
value.

- `Result.ok()` returns `Ok<void>`; `Result.ok(value)` returns `Ok<T>` as before
- `Ok<T>` and `Result<T, E>` default type parameters to `void` and
`UseCaseError`
- `flatMap` widened to allow distinct error types in chained operations
8 changes: 8 additions & 0 deletions packages/ddd/src/shared/domain/__tests__/result.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ describe('result', () => {
expect(result.value).toBe(value);
});

it('creates a frozen ok result without a value', () => {
const result = Result.ok();

expect(result).toEqual({ value: undefined, kind: 'ok' });
expect(Object.isFrozen(result)).toBe(true);
expect(Result.isOk(result)).toBe(true);
});

it('creates a frozen err result with the given error', () => {
const error = new InvalidValueError('invalid');
const result = Result.err(error);
Expand Down
2 changes: 2 additions & 0 deletions packages/ddd/src/shared/domain/__tests__/result.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ class SampleAppError extends ApplicationError {
}

const okNumber = ResultNs.ok(42);
const okVoid = ResultNs.ok();
const errDomain = ResultNs.err(new InvalidStateError('bad'));
const errApp = ResultNs.err(new SampleAppError());

expectType<Ok<number>>(okNumber);
expectType<Ok<void>>(okVoid);
expectType<Err<InvalidStateError>>(errDomain);
expectType<Err<SampleAppError>>(errApp);

Expand Down
85 changes: 38 additions & 47 deletions packages/ddd/src/shared/domain/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type { UseCaseError } from '../use-case.error';
/**
* Successful outcome of an application use case.
*/
export type Ok<T> = Readonly<{
export type Ok<T = void> = Readonly<{
kind: 'ok';
value: T;
}>;
Expand All @@ -21,77 +21,70 @@ export type Err<E extends UseCaseError> = Readonly<{
/**
* Explicit success/failure for application-layer use cases.
*
* Domain entities and value objects throw on invariant violations;
* application services return Result instead of throwing expected failures.
* Domain entities and value objects may throw on invariant violations.
* Application services should return Result for expected use-case failures.
*
* @template T Success value type
* @template E Per-use-case error union (`ApplicationError`, `DomainError`, etc.)
* @template T Success value type. Defaults to void for command use cases.
* @template E Per-use-case error union.
*/
export type Result<T, E extends UseCaseError> = Err<E> | Ok<T>;
export type Result<T = void, E extends UseCaseError = UseCaseError> =
| Err<E>
| Ok<T>;

function freeze<R extends Result<unknown, UseCaseError>>(result: R): R {
return Object.freeze(result);
}

/**
* Factory and combinators for {@link Result}.
*
* @example
* ```typescript
* function login(id: string): Result<Session, AuthInvalidCredentialsError> {
* if (!valid) return Result.err(new AuthInvalidCredentialsError(id));
* return Result.ok(session);
* }
*
* return Result.flatMap(loadUser(id), (user) => issueSession(user));
* ```
*/
function ok(): Ok<void>;
function ok<T>(value: T): Ok<T>;
function ok<T>(value?: T): Ok<T | void> {
return freeze({ kind: 'ok', value });
}

export const Result = {
/**
* Pattern-matches a Result into a single output value.
*/
match<T, E extends UseCaseError, U>(
result: Result<T, E>,
handlers: {
ok: (value: T) => U;
err: (error: E) => U;
},
): U {
if (result.kind === 'err') {
return handlers.err(result.error);
}
return result.kind === 'err'
? handlers.err(result.error)
: handlers.ok(result.value);
},

return handlers.ok(result.value);
/**
* Chains another Result-producing operation.
*/
flatMap<T, E extends UseCaseError, U, F extends UseCaseError = E>(
result: Result<T, E>,
fn: (value: T) => Result<U, F>,
): Result<U, E | F> {
return result.kind === 'err' ? result : fn(result.value);
},

/**
* Transforms the error value.
*/
mapError<T, E extends UseCaseError, F extends UseCaseError>(
result: Result<T, E>,
fn: (error: E) => F,
): Result<T, F> {
if (result.kind === 'ok') {
return result;
}

return Result.err(fn(result.error));
},

flatMap<T, E extends UseCaseError, U>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>,
): Result<U, E> {
if (result.kind === 'err') {
return result;
}

return fn(result.value);
return result.kind === 'ok' ? result : Result.err(fn(result.error));
},

/**
* Transforms the success value.
*/
map<T, E extends UseCaseError, U>(
result: Result<T, E>,
fn: (value: T) => U,
): Result<U, E> {
if (result.kind === 'err') {
return result;
}

return Result.ok(fn(result.value));
return result.kind === 'err' ? result : Result.ok(fn(result.value));
},

isErr<T, E extends UseCaseError>(result: Result<T, E>): result is Err<E> {
Expand All @@ -106,7 +99,5 @@ export const Result = {
return freeze({ kind: 'err', error });
},

ok<T>(value: T): Ok<T> {
return freeze({ kind: 'ok', value });
},
ok,
} as const;
Loading