-
-
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
Discussion: Preferred operator/keyword for safe assignment #4
Comments
Worth pointing out that these aren't just different syntax options, they also already imply some differences about how they work; options 2 and 4 definitely look like expressions, e.g. something like |
@Not-Jayden You've shared the results page, maybe worth to update to the voting one. |
Yep great callouts. I was curious about the I was wondering if it would make more sense as a modifier of sorts rather than a declarator, so you could choose to do |
I can't say I'd be that concerned about confusing So far, I don't see anything better than |
If we're bikeshedding the proposed syntax here .. |
@ThatOneCalculator just remove "results" from the url. Voting page |
why |
@zoto-ff That's addressed in the proposal. https://github.com/arthurfiorette/proposal-safe-assignment-operator#why-not-data-first |
Fair take. I prefer to think of it as an inline |
I also dislike using However, I do like something about the last approach with However coupled with optional chaining, it could simply return |
The "try (as throw)" reminds me of Scala Try util so maybe it's already invented. To the tweet: |
Here are some examples to clarify what I was thinking With the
|
Agree. If you leave out await, value in [error, value] should be a promise. With try await it should be the resolved promise. |
Third option limit usage of a result with I think this option should be disqualified. |
I have a question about entire function canThrow() {
if (Math.random() > 0.5) {
throw undefined
} else {
return undefined
}
} upd: topic is raised already #3 (comment) |
To avoid confusion with const [res, err] = trycatch await fetch(“…”); It’s not as aesthetically pleasing as “try”, but “trycatch” is clearer and is easy to identify when scanning through code. |
I don't think that it would be confusing because the context seems pretty different to me. try catch is statement while this would be expression. try catch is followed by curly while this would not be as JS does not support block expressions... well actually if they would be supported some day, it might be confusing when I think about it. Even for parser I guess... Alright, I think that I agree with you in the end 😄 Assuming block expressions exist, following code would mean "ignore the possible exception" (returned value [err, data] is ignored) however it's pretty confusing with classical try catch stmt. If catch/finally would not be syntactically required, parser would be helpless. Something as suggested try {
if (Math.random() > 0.5) {
throw new Error("abc");
}
doSomething();
} |
With |
Personally I don't like the Also it might be too much to wrap head around when it gets to the optional chaining. I can say that I trust you that it doesn't conflict anywhere however it seems to me that it requires a lot of thinking about the behaviour... But I might be wrong, maybe it's just needed to get used to it. EDIT: the |
Just throwing it out there, when I saw the proposal I initially thought it was for destructuring an optional iterable, kinda like the Nullish coalescing assignment but a little different (allowing the right side of an assignment to be nullish in the destructure). const something: number[] | undefined = [1, 2, 3]; // or = undefined;
const [a, b] ?= something;
We have a prior notion of settled promises, which would fit in this space for promises specifically (mentioned here too) const [{ status, reason, value }] = await Promise.allSettled([fetch("/")]);
// console.log({ status, reason, value });
// {status: 'fulfilled', reason: undefined, value: Response} For promises we could have a nicer function here... const { status, reason, value } = await Promise.settled(fetch("/")); Then it brings up, is this what is wanted, but for any object const { reason, value } [settled] something This closes any question of this vs that first (as ordering is optional), and drops the comparison to destructing iterator values. It also allows any additional keys to be defined on that returned value, not just Giving an example with a symbol not yet suggested,
const { value } ~ something Then with a promise it can be reasoned with maybe... const { value } ~ await promise This does force a rename instead of an iterator like destructing if you didn't want to use Instead of For functions, if it was to settle a function call, all has to be a single statement. const { value } ~ action() const { value } ~ await action() Outtakesconst { value, reason } settle something const { value, reason } settle await promise ... Or just cause I wrote the word "maybe" earlier const { value, reason } maybe something const { value, reason } maybe await promise |
Without |
Agreed, I had originally const { value } ~ something
const { value } ~ await promise
const { value } ~ action()
const { value } ~ await action()
const { value } ~= something
const { value } ~= await promise
const { value } ~= action()
const { value } ~= await action() Both are reasonable. It feels obvious though that something else is happening here. This is not assignment until destructing right? Unless there is some way to assign this intermediate representation, which, actually makes sense too const settled ~= await action();
if (settled.status === "fulfilled") {
console.log(settled.value);
} else {
console.log(settled.reason);
} |
Well maybe it should't be an assignment at all. What if I want to pass it directly as parameter? doSomethingWithResult([some keyword] await f()) |
const settled ~= await action();
doSomethingWithResult(settled) |
Obviously, but keyword would allow me do it directly. |
(Comments too quick, had edited to include but will shift down 😄) But if just a single expression doSomethingWithResult(~= await action()) Or the reduced doSomethingWithResult(~ await action()) doSomethingWithResult(~ syncAction()) This shows where |
Possibly, but if I understand correctly all the behaviour, then all these would be possible despite doing the same: const result ~= action();
const result = ~= action();
const result = ~ action() It seems to me that ~= works as both binary and unary operator. I think that |
Definitely interesting. Maybe in that case the try or trycatch keyword would be better? |
Without the equals, as a unary operator only, it would be turning the value to its right into a settled object. Where assignment or destructing happens could then be outside of the problem space for the operator.
|
For anyone who might be interested in already "testing" how it feels without waiting for any syntax changes or the proposal to continue, I've created a minimal library with necessary type safety here: https://jsr.io/@backend/safe-assignment It takes the currently highest voted solution "try (as throw)" and translates from: const [error, data] = try mightFail();
const [error, data] = try await mightFail(); to const [error, data] = withErr(mightFail);
const [error, data] = await withErr(mightFail); I'm already evaluating with it, if it feels better than using the current try-catch, and noted down some caveats. |
@vikingair By the way, do you consider custom promise classes and different realms (see: isError Proposal)? |
@DScheglov Not yet, as soon as the proposal would be stage 3, and supported by TS, I'd likely migrate from the I also wasn't aware of an existing implementation. The thread here is already quite huge 😬 Looking at your implementation the only differences seem to be:
|
The proposal addresses an existing issue: different REALMs (such as iframes and Node.js Virtual Machine modules). Additionally, I encountered a case when jest overrides the
There are dozens of similar implementations )
Are you sure that is correct? If someone throws something that isn't an error, do they perhaps expect to catch something that isn't an error (#30 (comment))? Meaning the following code must work without breaking the code that calls it. function fn() {
const [error, result] = try operation();
if (error) {
if (error instanceof MyVerySpecificError) return null;
throw error;
}
return 'ok';
} Now it seems it is better to return the following tuple: Or something like that:
Because converting an error into something else may result in a different flow splitting behavior than with a standard catch, that can cause some errors will not be handled correctly. |
@DScheglov Thanks for your hints, but so far I created only custom error classes that extend the Wrapping the thrown thing into an error is mainly done to push best practices of avoiding to throw anything that isn't an error. I know it can cause issues with a lot of existing code. My implementation is just "one" implementation. Very likely not the best or most appropriate. Shouldn't be taken as a guideline implementation for this proposal but rather to get a feeling of how it is to write JS following that syntax. Getting a feeling of it feels weird or cumbersome to write JS error handling like that. |
If something can cause the issues with existing code how it could be a best practice? :) In any case, if you wrap something, please ensure that someone can unwrap this something. |
I'm glad to see a lot of momentum around the " However, I think it's problematic to "return" a tuple in an attempt to mirror Go's syntax. Any functional language (or Rust) would be a better example, IMO feeling far more idiomatic. Something like: let processedResult;
const maybeResult = try doSomething();
if (maybeResult.ok) {
processedResult = doSomethingWithResult(maybeResult.result);
} else {
processedResult = doSomethingElseInstead(maybeResult.error);
} I don't feel like this, by itself, has much of an advantage over a traditional const processedResult = (try doSomething())
.unwrapOrElse(doSomethingElseInstead)
.then(doSomethingWithResult)
.finally(...) (Note: I used To be fair, I'm not sold on introducing another meaning/purpose for try {
const result = (try doSomething())
.unwrapOrElse(doSomethingElseInstead);
const processedResult = doSomethingWithResult(result);
} finally {
//...
} |
It's a great idea. However, this means we also need to introduce the Additionally to interface Resultable<T, E> {
[Symbol.result](): { value: T, ok: true } | { error: E; ok: false }
} And two operators: declare async function createUser(userData: UserData): Promise<Result<User, CreateUserError>>;
async function signUp(userData: UserData) {
// --- snip ---
const user = unwrap await createUser(userData);
// assigns user with value of type User OR
// returns Result<never, CreateUserError> (wrapped to Promise.resolve)
} The code bases that: class Result<T,E> implements Resultable<T, Result<never, E>> {
// -- snip --
} The @ljharb , @arthurfiorette |
I’m not clear on why a symbol would be needed? |
The
The type Maybe<T> = Some<T> | None;
class Some<T> implements Resultable<T, never> {
[Symbol.result]() {
return { ok: true, value: this.value }
}
}
class None implements Resultable<never, None> {
[Symbol.result]() {
return { ok: false; error: this };
}
} And later the correspondent instance could be declare async function getUserByName(userName: string): Promise<Maybe<User>>;
function findUser(userName: string): Promise<Maybe<Omit<User, "passwrod"> & { password?: never }>> {
const user = unwrap await getUserByName(userName);
delete user.password;
return some(user);
// Where `some` is
// const some = <T>(value: T): Some<T> => new Some(value);
} So, the Now, we are using the Actually, the |
That seems like overkill to me - other types should just have a way to vend a Result. |
So perhaps all types should just vend an Array? Why do we need a We already have many existing solutions, especially in TypeScript, and we want to move away from generators, aiming for a simpler way to handle monadic types in imperative code. The idea is that the |
Because an iterator doesn’t need all the items up front, an array does - there’s a massive difference between iteration and a two-path container. Protocols make things slower and more complex, and should only exist when it’s necessary. An operator for unwrap feels unnecessary too - just make it a Result method. |
Could you please clarify how it should work? |
I would assume that it would be something like |
Almost. Rust has type use std::num::ParseIntError;
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
let first_number = first_number_str.parse::<i32>()?; // <<<<< ? is applied here
let second_number = second_number_str.parse::<i32>()?; // <<<<< ? is applied here
Ok(first_number * second_number)
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n is {}", n),
Err(e) => println!("Error: {}", e),
}
}
fn main() {
print(multiply("10", "2"));
print(multiply("t", "2"));
} In TS and correspondently in JS we need to be able to write the same; class ParseIntError extends Error {};
function parseInteger(input: string): Result<number, ParseIntError> {
const int = parseInt(input, 10);
if (isNaN(int)) return err(new ParseIntError(`${input} is not an int`));
return ok(int);
}
function mult(inputA: string, inputB: string): Result<number, ParseIntError> {
const a = unwrap parseInteger(inputA);
const b = unwrap parseInteger(inputB);
return ok(a * b);
}
function print(result: Result<number, ParseIntError>) {
result.match({
ok: (value) => console.log(`n is ${value}`),
err: ({ message }) => console.error(`Error: ${message}`)
});
}
function main() {
print(mult("10", "2"));
print(mult("t", "2"));
} Now we have to write the same code using generators: import { type Result, ok, err, Do } from 'resultage';
class ParseIntError extends Error {};
function parseInteger(input: string): Result<number, ParseIntError> {
const int = parseInt(input, 10);
if (isNaN(int)) return err(new ParseIntError(`${input} is not an int`));
return ok(int);
}
const mult = (inputA: string, inputB: string): Result<number, ParseIntError> =>
Do(function*() {
const a = yield* parseInteger(inputA);
const b = yield* parseInteger(inputB);
return ok(a * b);
});
function print(result: Result<number, ParseIntError>) {
result.match(
(value) => console.log(`n is ${value}`),
({ message }) => console.error(`Error: ${message}`),
);
}
function main() {
print(mult("10", "2"));
print(mult("t", "2"));
} see in TS Playground or in CodeSandbox The function f() {
const value = unwrap result;
return value;
} is the same for: function f() {
if (result.isErr) return result;
const value = result.unwrap();
return value;
} |
ok, well, adding a new keyword that’s another kind of |
But this isn't new. We already have |
I suspect you'll find that generator syntax isn't universally loved. |
Of course not. First of all, it's an inappropriate use of generators. |
Simple userland module also https://github.com/LuKks/like-safe const safe = require('like-safe')
// Sync
const [res1, err1] = safe(sum)(2, 2) // => [4, null]
const [res2, err2] = safe(sum)(2, 'two') // => [null, Error]
// Async
const [res3, err3] = await safe(sumAsync)(2, 2) // => [4, null]
const [res4, err4] = await safe(sumAsync)(2, 'two') // => [null, Error]
// Shortcut for Promises
const [res5, err5] = await safe(sumAsync(2, 2)) // => [4, null]
const [res6, err6] = await safe(sumAsync(2, 'two')) // => [null, Error] |
Why create a very special and opinionated syntax for a structure which can be replaced by a single function wrapper? Moreover, handling error/data is proved to be error-prone in languages adapted this pattern of error handling (like Go). |
Do you have any references for that? I'd be really interested to take a look! |
@DScheglov I understand that JS might not have these issues due to having the way to opt out of this Safe Assignment and use plain old try-catch. But here comes the question - if you want to opt out of this feature in complex cases, why integrate it as a language feature, considering its behavior can be replaced with a single function call. |
I'm not sure that I got your point correctly. This video is about fixing the issue of manual propagation by introducing the Unfortunatelly the original artical (or post) couldn't be reached. Perhaps you have another link on that? |
Updated link: https://www.boramalper.org/blog/go-s-error-handling-sucks-a-quantitative-analysis/
|
@AlexanderFarkas , @fabiancook Thank you for pointing out the error propagation issue. Yes, Go's error handling is definitely not perfect. :) But this proposal doesn't break the auto-propagation of exceptions in JS, fortunately - it is just a new way to catch and handle errors, nothing more. Considering the goroutines, I guess Go's panic is not used for "exceptions" for the same reason why Node.js callback-API doesn't throw errors but instead invoke the callback with an error. In JS we have exceptions and rejected promises that make our life much easier. Also we can use unions to model non-exceptional errors similar to Rust's So, despite that initial propose was inspired by Go's, it will not bring the problems of Go's error handling to JS/TS. It will bring other ones, but not Go's. And finally regarding the
It is normal for JS to replace some easy-to-implement features with language or "standard library" functionality. Actually in this specific case, it is not easy to implement, and moreover, it is difficult to make the solution intuitively clear for different developers—I ended up writing two pages of README for my own implementation to cover the edge-cases. So, adding the |
I came across this proposal after wondering if there was a cleaner way to handle try catch blocks and this new decision around a One thought I have looking at this is that it's similar to if/when expressions in Kotlin but I'm wondering if it could be expanded to include the Something like this could be useful.
Or do this, though I'm not sure if the return could be inferred like in Kotlin or would need to be explicit. This would be useful if you have something a little more complex with side effects or additional logic though I don't think this would necessarily be best practice.
I'm very new to looking at proposals so if this isn't possible, would overly complicate things, or should be a separate idea, that's totally fine. |
@merlinstardust I don't see any benefit in adding the "finally" addition 🤔. With the safe assignment operator you know that you'll reach the next line and can just run your "finally" function on the next line of code. I.e. const [error, result] = try something();
someFinalFunction(); |
Creating this issue just as a space to collate and continue the discussion/suggestions for the preferred syntax that was initiated on Twitter.
These options were firstly presented on Twitter here:
1.
?=
(as!=
)I generally agreed with this comment on the current proposed syntax:
2.
try
(asthrow
)Alternative suggestion for the await case from Twitter here
3.
try
(asusing
)4.
?
(as!
in TypeScript)👉 Click here to vote 👈
Please feel free to share any other suggestions or considerations :)
The text was updated successfully, but these errors were encountered: