Ant
Ant

Reputation: 191

Either monad in TypeScript + pattern matching

I'm trying to implement Either monad in TypeScript.

interface Either<TResult, TError> {

  flatMap<R>(f: (value: TResult) => Either<R, TError>): Either<R, TError>;

  match<R>(result: (success: TResult) => R, error: (err: TError) => R): R;
}

class Left<TResult, TError> implements Either<TResult, TError> {

  public constructor(private readonly error: TError) {}

  public flatMap<R>(f: (value: TResult) => Either<R, TError>): Either<R, TError> {

    return new Left(this.error);
  }

  public match<R>(success: (value: TResult) => R, error: (err: TError) => R): R {
    return error(this.error);
  }
}

class Right<TResult, TError> implements Either<TResult, TError> {

  public constructor(private readonly value: TResult) {}

  public flatMap<R>(f: (value: TResult) => Either<R, TError>): Either<R, TError> {

    return f(this.value);
  }

  public match<R>(success: (result: TResult) => R, error: (err: TError) => R): R {
    return success(this.value);
  }
}

class ResourceError {
}

class ObjectNotFound  {

  public constructor(public readonly msg: string, public readonly code: number) {
  }
}

function f1(s: string): Either<number, ObjectNotFound> {
  return new Right(+s);
}

function f2(n: number): Either<number, ResourceError> {
  return new Right(n + 1);
}

function f3(n: string): Either<string, ObjectNotFound> {
  return new Right(n.toString());
}

const c = f1('345') 
// (*) line 58 - error: Type 'Either<number, ResourceError>' is not assignable to type 'Either<number, ObjectNotFound>'. 
  .flatMap(n => f2(n)) 
  .flatMap(n => f3(n));

const r = c.match(
  (result: string) => result,
  (err: ObjectNotFound) => err.msg
);

Link to playground.

As different functions can potentially generate errors of different types, flatMap chaining breaks.

I assume, overall intention is clear from the code. And I hope I've chosen the right tool (either monad).

Can anyone suggest a fix to make it all work?

[UPDATE]: Thanks to SpencerPark for the nudge in right direction. The full implementation with .match working as originally intended is below (inspired by this post):

type UnionOfNames<TUnion> = TUnion extends { type: any } ? TUnion["type"] : never

type UnionToMap<TUnion> = {
  [NAME in UnionOfNames<TUnion>]: TUnion extends { type: NAME } ? TUnion : never
}

type Pattern<TResult, TMap> = {
  [NAME in keyof TMap]: (value: TMap[NAME]) => TResult;
}

type Matcher<TUnion, TResult> = Pattern<TResult, UnionToMap<TUnion>>;


interface Either<TResult, TError> {

  flatMap<R, E>(f: (value: TResult) => Either<R, E>): Either<R, E | TError>;

  match<R>(success: (result: TResult) => R, error: Matcher<TError, R>): R;
}

class Left<TResult, TError extends { type: any }> implements Either<TResult, TError> {

  public constructor(private readonly error: TError) {}

  public flatMap<R, E>(f: (value: TResult) => Either<R, E>): Either<R, E | TError> {
    return new Left(this.error);
  }

  public match<R>(success: (result: TResult) => R, error: Matcher<TError, R>): R {
    return (error as any)[this.error.type](this.error);
  }
}

class Right<TResult, TError> implements Either<TResult, TError> {

  public constructor(private readonly value: TResult) {}

  public flatMap<R, E>(f: (value: TResult) => Either<R, E>): Either<R, E | TError> {
    return f(this.value);
  }

  match<R>(success: (result: TResult) => R, error: Matcher<TError, R>): R {
    return success(this.value);
  }
}

class ResourceError {
  type = "ResourceError" as const

  public constructor(public readonly resourceId: number) {}
}

class ObjectNotFound {
  type = "ObjectNotFound" as const

  public constructor(public readonly msg: string, public readonly timestamp: string) {}
}

class DivisionByZero {
  type = "DivisionByZero" as const
}

class NetworkError {
  type = "NetworkError" as const

  public constructor(public readonly address: string) {}
}


function f1(s: string): Either<number, DivisionByZero> {
  return new Right(+s);
}

function f2(n: number): Either<number, ResourceError> {
  return new Right(n + 1);
}

function f3(n: number): Either<string, ObjectNotFound> {
  return new Right(n.toString());
}

function f4(s: string): Either<number, NetworkError> {
  return new Left(new NetworkError('someAdress'));
}

const c = f1('345')
  .flatMap(n => f2(n))
  .flatMap(n => f3(n))
  .flatMap(s => f4(s));

const r = c.match(
  (result: number) => result.toString(),
  {
    ObjectNotFound: (value: ObjectNotFound) => 'objectNotFound',
    ResourceError: (value: ResourceError) => 'resourceError',
    DivisionByZero: (value: DivisionByZero) => 'divisionByZero',
    NetworkError: (value: NetworkError) => value.address
  }
);

console.log(r);

Updated playground is here.

[UDPDATE 2]: 'catchall' clause support:

type DiscriminatedUnion<T> = { type: T }

type UnionOfNames<TUnion> = TUnion extends DiscriminatedUnion<string> ? TUnion["type"] : never

type UnionToMap<TUnion> = {
  [NAME in UnionOfNames<TUnion>]: TUnion extends DiscriminatedUnion<NAME> ? TUnion : never
}

type Pattern<TResult, TMap> = {
  [NAME in keyof TMap]: (value: TMap[NAME]) => TResult
} | ({
  [NAME in keyof TMap]?: (value: TMap[NAME]) => TResult
} & { catchall: (value: DiscriminatedUnion<string>) => TResult})

// ... SKIPPED ...

const r1 = c.match(
  (result: string) => result,
  {
    // when there is the 'catchall' case, others are optional. But still only the possible cases can be listed.
    ObjectNotFound: (value: ObjectNotFound) => value.msg + value.timestamp,
    catchall: (value: DiscriminatedElement<string>) => 'catchall ' + value.type
  }
);

Upvotes: 2

Views: 1723

Answers (1)

SpencerPark
SpencerPark

Reputation: 3506

We can change the signature of flatMap to the following:

flatMap<R, E>(f: (value: TResult) => Either<R, E>): Either<R, E | TError>;

Here the error type is either the error from the previous computation or the one returned by the current one. Conceptually that follows from what Either models. The type at the end of the chain in your example is Either<string, ObjectNotFound | ResourceError> as expected. (After fixing what looks like a typo, f3(n: number)...).

The overall computation could return an ObjectNotFound error or ResourceError depending on what step failed.

The final match now properly raises a type error because the error function doesn't handle the case where a ResourceError is raised as possible from f2.

See the updated playground link

Upvotes: 1

Related Questions