1

Inspired by neverthrow and this nice answer I'm experimenting with a "flat result API". The target is to write code like this with full type-safety:

const res =
  Math.random() < 0.5
    ? ok({ id: 42 })
    : err({ message: "something went wrong" });

if (res.isOk()) {
  console.log("Ok:", res.id);
} else {
  console.log("Error:", res.message);
}

// Side note: The difference to neverthrow is that value/error aren't nested, i.e.,
// no unwrapping is needed at the cost of only supporting objects.

I have achieved that with the following implementation. However it required an unexpected "hack":

export type Result<T, E> = Ok<T> | Err<E>;

type Ok<T> = OkTag & T;
type Err<E> = ErrTag & E;

class OkTag {
  private _isOk = true; // Why do I need this?
  constructor(value: Record<any, any>) {
    Object.assign(this, value);
  }
  isOk(): this is OkTag {
    return true;
  }
  isErr(): this is ErrTag {
    return false;
  }
}

class ErrTag {
  private _isOk = false; // Why do I need this?
  constructor(error: Record<any, any>) {
    Object.assign(this, error);
  }
  isOk(): this is OkTag {
    return false;
  }
  isErr(): this is ErrTag {
    return true;
  }
}

export const ok = <T extends Record<any, any>>(value: T): Ok<T> => {
  return new OkTag(value) as Ok<T>;
};

export const err = <E extends Record<any, any>>(error: E): Err<E> => {
  return new ErrTag(error) as Err<E>;
};

The example above compiles and seems to behave correctly. However if I remove the unused _isOk property it fails to compile with the errors

In the first branch of the if:

Property 'id' does not exist on type 'Ok<{ id: number; }> | Err<{ message: string; }>'.
  Property 'id' does not exist on type 'Err<{ message: string; }>'.

In the second branch of the if:

Property 'message' does not exist on type 'never'.

Apparently the type guards stop working.

I also noticed that the value of the property doesn't matter -- unless I'm making a wrong conclusion the code seems to work with private _dummy = 0 in both classes as well, i.e., the property doesn't have any influence on runtime.

Why is this dummy property needed / is there a way to avoid it?

bluenote10
  • 23,414
  • 14
  • 122
  • 178
  • This is an interesting question which I have to think about more. Unions are tough but having an `ErrTag` assert that it is an `ErrTag` feels like an awful anti-pattern. Don't we already know that? – Linda Paiste Feb 25 '21 at 23:38
  • @LindaPaiste I'm not sure about it either. It only really makes sense in the context of the type union `Result`. Stand-alone usage isn't really meaningful. There are alternatives to discriminating the union, but attaching these constant-returning functions to the prototype may actually be more efficient compared to ~~really looking up a dynamic `_isOk` property or using `instanceof`~~ (I better take that back :)). Also the resulting API is "fluent", i.e., users can write `x.isOk()` with auto-completion, instead of having to import free-floating `isOk`/`isErr` helpers everywhere. – bluenote10 Feb 26 '21 at 08:11
  • There are definitely ways to structure this that avoid having to make assertions. I like putting the success/error in a separate property: https://tsplay.dev/QmbxEw (this prevents any weird edge cases where the value itself has properties `isOk` or `isError`). This is more like what you had: https://tsplay.dev/Ymp1xW It's a lot easier to narrow based on values vs function returns. The function returns don't have the same effect: https://tsplay.dev/ymAB8W I wish I could give you a good answer as to WHY having a private property changes behavior. – Linda Paiste Feb 26 '21 at 20:20
  • @LindaPaiste even without an answer to the original question: Very inspiring, thanks a lot! – bluenote10 Feb 26 '21 at 21:20

0 Answers0