TypeScript 中的 monad + 模式匹配

Either monad in TypeScript + pattern matching

我正在尝试在 TypeScript 中实现 Either monad。

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 到 playground.

由于不同的函数可能会产生不同类型的错误,因此 flatMap 链接中断。

我假设,从代码中可以清楚地看出总体意图。我希望我选择了正确的工具(monad)。

任何人都可以提出修复建议以使其全部正常工作吗?

[更新]: 感谢 SpencerPark 向正确的方向推动。 .match 按最初预期工作的完整实现如下(受 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);

更新后的游乐场是 here

[UDPDATE 2]: 'catchall' 子句支持:

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
  }
);

我们可以将flatMap的签名改成如下:

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

这里的错误类型要么是前一次计算的错误,要么是return当前计算的错误。从概念上讲,这遵循 Either 模型。在您的示例中,链末尾的类型是预期的 Either<string, ObjectNotFound | ResourceError> 。 (修正看起来像是打字错误的内容后,f3(n: number)...)。

整体计算可能 return 一个 ObjectNotFound 错误或 ResourceError 取决于失败的步骤。

最终匹配现在可以正确引发类型错误,因为 error 函数不处理 ResourceError 尽可能从 f2.[=21= 引发的情况]

updated playground link