Search code examples
typescripttypesdomain-driven-designhexagonal-architecture

Union Type for Exceptions


I am looking for a solution where I might misuse TypeScript's type system.

I have a service which provides a port for a repository (Interface that the Repository must implement) since the service must not know the concrete implementation of the repository. Because of this fact the Interface must also provide a definition of the errors the service can handle.

For this I use ts-results.

I could easily define the errors as strings but I would like to provide some further information from the repository to the service in the case of error. So I tried to define the Errors as Union Type of various Error Classes. The problem is that every default error matches the signature of the more specialized errors.

Thus, in the repository, there is no way to prevent passing any other error to the service (port).

// The Port Definition
export abstract class FriendshipRepositoryPort implements IFriendshipRepository {
    abstract load(
        userIdA: UserId, userIdB: UserId
    ): Promise<Result<IFriendshipAggregate, FriendshipRepositoryErrors>>;

    abstract persist(
        friendship: IFriendshipAggregate
    ): Promise<Result<void, FriendshipRepositoryErrors>>;
}
// repo implementation
async persist(friendship: IFriendshipAggregate): Promise<Result<void, FriendshipRepositoryErrors>> {
        // ... preparing persisting the entities
        try {
            return new Ok(await this._persistenceManager.execute(querySpec));
        } catch (e) {
            console.error(e);
            // FIXME: This should not be possible!
            return new Err(new RuntimeError());
        }
    }
// error definition
export type FriendshipRepositoryErrors = UserNotFoundError
    | DomainRuleViolation
    | DatabaseWriteError
    | DatabaseReadError;

Is there any way to define the Result to only accept instances of the given classes (or there heirs) as error type?

I also created a playground to demonstrate the problem in a very small example.


Solution

  • I think the best solution here is to use discriminated union types. While this looks cumbersome it might be the most secure and explicit way to tell what errors may occur and more specifically what errors can be injected as error into your Result.

    See this playglound how the compiler behaves.

    class ErrorA extends Error{
        #type = ErrorA.name;
    }
    class ErrorB extends Error {
        #type = ErrorB.name;
    }
    
    class NotAllowedError extends Error {
    }
    
    class ErrorC extends ErrorB {
        #type = ErrorC.name;
    }
    
    type AllowedErrorTypes =  ErrorA | ErrorB;
    
    function handleError(error: AllowedErrorTypes): void {
        if(error instanceof ErrorA) {
            throw error;
        }
        if(error instanceof ErrorB) {
            throw error;
        }
        // no other options left.
        // If I add another allowedError the compiler is going to complain
        const exhausted: never = error;
    }
    
    handleError(new ErrorA());
    handleError(new ErrorB());
    handleError(new NotAllowedError());
    handleError(new ErrorC());