-
-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Suggestion: Return a discriminable object instead of a tuple array #3
Comments
Typescript type narrowing is not broken with this apporach. https://github.com/arthurfiorette/tuple-it The above package I created follows the same code but uses |
However moving to an object (with |
Oh wow I stand corrected, my mind is blown 🤯 I've still been working under the assumptions of pre TypeScript 4.6 where destructured discriminated narrowing wasn't a thing. Though I guess it's worth considering anyway. That said I guess I do still stand by the suggestion for it to be an object over the tuple approach. I can definitely see the pros for a tuple, particularly:
I still generally favour the explicitness/foolproof-ness of having the named
I also only just learnt it's possible to use |
UPD: this comment is something like dreaming ... Unfortunately there is no graceful way to mix typed errors and exceptions, see my later comment I also think that it is better to use an object instead of tuple, more then type narrowing must support types like: function sqrt(x: number): number {
if (x < 0) throw new Error('SQRT: expected x to be greater or equal zero, but negative value recieved');
return Math.sqrt(x);
}
const result ?= sqrt(x);
if ("error" in result) {
// error is type of `unknown` here
throw resul.error;
}
const root: number = result.data; // data is type of `number` here But if we do something like that: type Ok<T> = {
[Symbol.result](): { data: T }
}
type Err<E> = {
[Symbol.result](): { error: E }
}
type Result<E, T> = Err<E> | Ok<T>;
const ok = <T, >(data: T): Ok<T> => ({ [Symbol.result]: () => ({ data }) });
const err = <E, >(error: E): Err<E> => ({ [Symbol.result]: () => ({ error }) });
function sqrt(x: number): Result<'ERR_SQRT_NEGATIVE_VAL', number> {
if (x < 0) return err('ERR_SQRT_NEGATIVE_VAL');
return ok(Math.sqrt(x));
}
const result ?= sqrt(x);
if ("error" in result) {
// error is type of `'ERR_SQRT_NEGATIVE_VAL'`
throw resul.error;
}
const root: number = result.data; // data is type of number here UPD: Also it is better to avoid using type Ok<T> = {
[Symbol.result](): { isError: false; data: T; }
}
type Err<E> = {
[Symbol.result](): { isError: true; error: E; }
}
// -- snip --
if (result.isError) {
result.error; // we know that "error" is in result, so we can access it
} else {
result.data; // we know that "data" is in result, so we can access it
} |
What happens if I'm aware that this isn't a normal occurrence and would be stupid to do, but // try to generate a random number but undefined is thrown.
// value will not be a number even though error is undefined.
function randomNumber() {
throw undefined
}
const [error, value] ?= randomNumber()
assert(error === undefined)
assert(value === undefined)
const { error, value } ?= randomNumber()
assert(error === undefined)
assert(value === undefined) It seems necessary to use a result object with a discriminator value. Prior art: // try to generate a random number asynchronously but undefined is thrown.
// use `Promise.allSettled()` to get an "outcome object" for the number.
// the outcome object's status is "rejected", reason is undefined, and value is not set.
async function asyncRandomNumber() {
throw undefined
}
async function asyncSettle(promise) {
const [result] = await Promise.allSettled([promise])
return result
}
const result = await asyncSettle(asyncRandomNumber())
result satisfies { status: "fulfilled", value: T } | { status: "rejected", reason: any }
assertEqual(result, { status: "rejected", reason: undefined }) |
Note: the object overhead might be more expensive for low-level usage (webGPU, encoding, realtime applications) -- imho its not worth the trade off edit: operative word is might (I guess the performance-conscious alternative would be an optional unwrap with no error unwrapping at all): const thing: FooType | undefined = try getFoo(); |
Just found out about this proposal, very excited! I'm also really glad to see others were thinking about future potential for a Result/Maybe/Option type. I think I think the Result object should have some kind of discriminated tag to help with type narrowing when interacting with object manually. It could be a Symbol or a read only string. This allows us to capture if the source of the result threw an error even if the Error path itself was the value You can model this interface in Typescript like this: type Result<Error, Data> =
(
[Error, null]
&
{ tag: 'Error'
, value: Error
}
)
|
(
[null, Data]
&
{ tag: 'Data'
, value: Data
}
) This means we get to keep the sugar but also have an extensible interface for future proposals/additions. |
I came here to post the same issue, but...
TIL 😄
I do not. 🤔 Destructuring a tuple makes it easy and natural (and necessary) to assign local names to both values. This becomes important when making multiple calls to functions that may return errors, in the same local scope. With objects, it's tempting to just name the return values as they're named in the returned object - but this falls apart of you need to make more than one call. You have to rename them then, which is rather clunky with object destructuring.
as opposed to:
Tuple return types encourage you to destructure and rename the values - it's the idiomatic "multiple return values" pattern in JS, and multiple return values is exactly what we have here. With the object approach, it's likely some people will prefer With tuples, no one wants to write I believe this is part of the reason why e.g. Solid and React uses tuple returns when creating getters and setters for state management - we're expecting to have several of these pairs in the same scope, it's the idiomatic multiple return value pattern in JS, and the returned values should be named at the call site, just as any singular return value from any other function call gets named at the call site. |
@JAForbes despite of my previous comment, it is not possible to include typed errors in this proposal because the function that returns a typed error can also throw. function stringify(value: unknown): Result<'ERR_FUNCTION_UNSERIALIZABLE', string> {
if (typeof value === 'function') return err('ERR_FUNCTION_UNSERIALIZABLE');
return JSON.stringify(value);
}
const x: { x?: unknown } = {};
x.x = x;
const result ?= stringify(x); Here we get an error that is definitelly is not So, I guess it is senseless to try type errors in this proposal, that is aimed to handle exceptions gracefully. Sure, we can try to do the following: type Result<Error, Data> =
(
[Error, null]
&
{ tag: 'Error'
, value: Error
}
)
|
(
[null, Data]
&
{ tag: 'Data'
, value: Data
}
)
|
(
[unknown, Data]
&
{ tag: 'Exception'
, value: unknown
}
) But it doesn't seem to simplify error handling both in TS and in JS |
But this proposal would absorb the throw so in this context its fine right? |
To be a bit more clear, the return type of the function isn't a |
To clarify, my opinion here is a lot more indifferent than it was originally after learning destructured type discrimination works.
I'd agree it's a relatively small learning curve, but still larger than having named properties. To your point that "no one wants to write I'm still keen to hear more perspectives as I think it's worth giving careful consideration, despite my current sway to neutrality 😅 |
I don't think that TypeScript should be the deciding factor in how JS should evolve. TypeScript should just evolve to match the language, not the other way around. That said,
const { error: errorOne, data: user } ?= await fetchUser('one');
if (errorOne) // handle
const { error: errorTwo, data: updated } ?= await updateUser('one', patch);
if (errorTwo) // handle
// vs
const [errorOne, user] ?= await fetchuser('one');
if (errorOne) // handle
const [errorTwo, updated] ?= await updateuser('one', patch);
if (errorTwo) // handle That would get annoying real fast. |
To summarise, what I was saying before I think we can have our cake and eat it too, just have an object with a |
Unfortunately, we cannot have But from the TS point of view it is better to have an object in any case: interface Result<T> {
[Symbol.result]():
| { isError: false, value: T }
| { isError: true, value: unknown }
} |
we will rue the day when TS finally solves this. 😅 (probably never though?) the interface Result { btw your type example doesn't look right? shouldn't it be something like this? interface Result<T> {
[Symbol.result]():
| { error: undefined, value: T }
| { error: unknown, value: unknown }
} although I'm not sure |
Exactly That's why it is better to have an explicit descriminator like I proposed. The interface Result<T> {
[Symbol.result]():
| { error: {}, value: undefined } // {} means any non-nullable value
| { error: undefined | null, value: T }
} or the following interface Result<T> {
[Symbol.result]():
| [{}, undefined]
| [undefined | null, T]
} But even in this case we have an issue with type narrowing:
But having the: interface Result<T> {
[Symbol.result]():
| { isError: false, value: T }
| { isError: true, value: unknown }
} Everything works as expected see TS Playground: Explicit Descriminator |
About the import 'tuple-it/register'
type WorkData = { id: string; title: string; }
async function work(promise: Promise<WorkData>) {
const [error, data] = await promise.tuple<unknown>()
// const [error, data] = await promise.tuple<{}>()
if (error) {
console.log('Operation failed!')
return false
}
/**
* Type 'WorkData | undefined' is not assignable to type 'WorkData'.
* Type 'undefined' is not assignable to type 'WorkData'.ts(2322)
*/
const value: WorkData = data;
console.log('Operation succeeded!')
return true
} Typescript in strict mode treats exceptions as |
Hmm, nice catch with the typings @DScheglov! However, if the error throw is not an error, the library catches it: |
I knew I'd ran into issues with this quirk recently, just couldn't recall how. I wonder if this necessitates the need to include an additional e.g. type Result<TData = unknown> =
{ success: true, data: TData, error: null } |
{ success: false, data: null, error: unknown };
function handleResult({success, error, data}: Result<"Oops!" | undefined, "Success!" | undefined>) {
if (success) {
console.log('Success:', data);
} else {
console.log('Error:', error);
}
} or even consolidate type Result<TData = unknown> =
{ success: true, value: TData } |
{ success: false, value: unknown };
function handleResult({ success, value }: Result<string | undefined, TypeError | undefined>) {
if (success) {
console.log('Success:', value);
} else {
console.log('Error:', value);
}
} |
I recommend looking in to prior art in JavaScript. From Promise rejected values are not typed because promises catch thrown values. This proposal has the same issue. From This takes promises and converts them to "outcome objects". Using it on a single promise is analogous to an asynchronous version of this proposal. const [outcome] = await Promise.allSettled([promise])
outcome satisfies { status: "resolved", value: T } | { status: "rejected", reason: unknown } From Responses have an |
As I wrote in one of other comments in TS we need a way to have a type errors. TS just highlights that. But yes, from the JS point of view there is no sense to have a typed errors but using the tuple allows to simplify naming. I see something like a compromise between the TS and JS needs:
This approach is more consistent:
|
@DScheglov we don't create JS to adapt to current TS code. This is wrong and i refuse to do it as well any other member of TC39. We first figure the best way to have it in JS and then TS adapts itself to best handle the new behavior. |
Closed by #30 |
The proposal introduces Symbol.result that just it become a part of ES brings the problem to TS. |
Taking inspiration from the
until
package and this Twitter discussion:for usage in TypeScript, an object is the only nice way to be able to narrow the type.
You can of course do something like this:
but that's a lot less explicit/prone to error, which can also be said for the destructuring approach in general, as it will be quite easy to forget the correct order for data and error, especially without TypeScript being involved.
In summary, I'm proposing to change the syntax/type to something like this:
EDIT: Disregard the
TError
generic usage, it would just always beunknown
when erroring.The text was updated successfully, but these errors were encountered: