DEV Community

Cover image for TypeScript with Go/Rust errors? No try/catch? Heresy.
Mateusz Piórowski
Mateusz Piórowski

Posted on • Updated on

TypeScript with Go/Rust errors? No try/catch? Heresy.

So, let's start with a little backstory about me. I am a software developer with around 10 years of experience, initially working with PHP and then gradually transitioning to JavaScript. Also, this is my first article ever, so please be understanding :)

I started using TypeScript somewhere around 5 years ago, and since then, I have never gone back to JavaScript. The moment I started using it, I thought it was the BEST programming language ever created. Everyone loves it, everyone uses it... it's just the best one, right? Right? RIGHT?

Yeah, and then I started playing around with other languages, more modern ones. First was Go, and then I slowly added Rust to my list (thanks Prime).

It's hard to miss things when you don't know different things exist.

What am I talking about? What common thing that Go and Rust share? ERRORS. The one thing that stood out the most for me. And more specifically, how these languages handle them.

JavaScript relies on throwing exceptions to handle errors, whereas Go and Rust treat them as values. You might think this is not such a big deal... but, boy, it may sound like a trivial thing; however, it's a game-changer.

Let's walk through these languages. We will not dive deep into each; we just want to know the general approach.

Let's start with JavaScript / TypeScript and a little game.

Give yourself 5 seconds to look at the code below and answer WHY we need to wrap it in try / catch.

try {
    const request = { name: "test", value: 2n };
    const body = JSON.stringify(request);
    const response = await fetch("https://example.com", {
        method: "POST",
        body,
    });
    if (!response.ok) {
        return;
    }
    // handle response
} catch (e) {
    // handle error
    return;
}
Enter fullscreen mode Exit fullscreen mode

So, I assume most of you guessed that even though we are checking for response.ok, the fetch method can still throw an error. The response.ok "catches" only 4xx and 5xx network errors. But when the network itself fails, it throws an error.

But I wonder how many of you guessed that the JSON.stringify will also throw an error. The reason why is that the request object contains the bigint (2n) variable, which JSON doesn't know how to stringify.

So the first problem is, and personally, I believe it's the biggest JavaScript problem ever: we DON'T KNOW what can throw an error. From a JavaScript error perspective, it's the same as:

try {
    let data = "Hello";
} catch (err) {
    console.error(err);
}
Enter fullscreen mode Exit fullscreen mode

JavaScript doesn't know, JavaScript doesn't care; YOU should know.

Second thing, this is a perfectly viable code:

const request = { name: "test", value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
    method: "POST",
    body,
});
if (!response.ok) {
    return;
}
Enter fullscreen mode Exit fullscreen mode

No errors, no linters, even though this can break your app.

Right now in my head, I can hear, "What's the problem, just use try / catch everywhere." Here comes the third problem; we don't know WHICH ONE throw. Of course, we can somehow guess by the error message, but for bigger services / functions, with a lot of places where error can happen? Are You sure You are handling all of them properly by one try / catch?

Ok, it's time to stop picking on JS and move to something else. Let's start with Go:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f
Enter fullscreen mode Exit fullscreen mode

We are trying to open a file, which is returning either a file or an error. And you will be seeing this a lot, mostly because we know which functions return errors, always. You never miss one. Here is the first example of treating the error as a value. You specify which function can return them, you return them, you assign them, you check them, you work with them.

It's also not so colorful, and it's also one of the things Go gets criticized for, the error-checking code, where if err != nil { .... sometimes takes more lines of code than the rest.

if err != nil {
    ...
    if err != nil {
        ...
        if err != nil {
            ... 
        }
    }  
}
if err != nil {
    ... 
}
...
if err != nil {
    ... 
}
Enter fullscreen mode Exit fullscreen mode

Still totaly worth the effort, trust me.

And finally Rust:

let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
    Ok(file) => file,
    Err(error) => panic!("Problem opening the file: {:?}", error),
};
Enter fullscreen mode Exit fullscreen mode

The most verbose of the three shown here and ironically the best one. So first of all, Rust handles the errors using its amazing Enums (they are not the same as TypeScript enums!). Without going into details, what is important here is that it uses an Enum called Result with two variants: Ok and Err. As you might guess, Ok holds a value and Err holds...surprise, an error :D.

It also has a lot of ways to deal with them in more convenient ways, to mitigate the Go problem. The most well-known one is the ? operator.

let greeting_file_result = File::open("hello.txt")?;
Enter fullscreen mode Exit fullscreen mode

The summary here is that both Go and Rust know wherever there might be an error, always. And they force you to deal with it right where it appears (mostly). No hidden ones, no guessing, no breaking app with a surprise face.

And this approach is JUST BETTER. BY A MILE.

Ok, it's time to be honest, I lied a little bit. We cannot make TypeScript errors work like the Go / Rust ones. The limiting factor here is the language itself; it just doesn't have proper tools to do that.

But what we can do is try to make it similar. And make it simple.

Starting with this:

export type Safe<T> =
    | {
          success: true;
          data: T;
      }
    | {
          success: false;
          error: string;
      };
Enter fullscreen mode Exit fullscreen mode

Nothing really fancy here, just a simple generic type. But this little baby can totally change the code. As you might notice, the biggest difference here is we are either returning data or error. Sounds familiar?

Also, second lie, we need some try / catch. The good thing is we only need at least two, not 100000.

export function safe<T>(promise: Promise<T>): Promise<Safe<T>>;
export function safe<T>(func: () => T): Safe<T>;
export function safe<T>(
    promiseOrFunc: Promise<T> | (() => T),
): Promise<Safe<T>> | Safe<T> {
    if (promiseOrFunc instanceof Promise) {
        return safeAsync(promiseOrFunc);
    }
    return safeSync(promiseOrFunc);
}

async function safeAsync<T>(promise: Promise<T>): Promise<Safe<T>> {
    try {
        const data = await promise;
        return { data, success: true };
    } catch (e) {
        console.error(e);
        if (e instanceof Error) {
            return { success: false, error: e.message };
        }
        return { success: false, error: "Something went wrong" };
    }
}

function safeSync<T>(func: () => T): Safe<T> {
    try {
        const data = func();
        return { data, success: true };
    } catch (e) {
        console.error(e);
        if (e instanceof Error) {
            return { success: false, error: e.message };
        }
        return { success: false, error: "Something went wrong" };
    }
}
Enter fullscreen mode Exit fullscreen mode

"Wow, what a genius, he created a wrapper for try / catch." Yes, you are right; this is just a wrapper with our Safe type as the return one. But sometimes simple things are all you need. Let's combine them together with the example from above.

Old one (16 lines):

try {
    const request = { name: "test", value: 2n };
    const body = JSON.stringify(request);
    const response = await fetch("https://example.com", {
        method: "POST",
        body,
    });
    if (!response.ok) {
        // handle network error
        return;
    }
    // handle response
} catch (e) {
    // handle error
    return;
}
Enter fullscreen mode Exit fullscreen mode

New one (20 lines):

const request = { name: "test", value: 2n };
const body = safe(() => JSON.stringify(request));
if (!body.success) {
    // handle error (body.error)
    return;
}
const response = await safe(
    fetch("https://example.com", {
        method: "POST",
        body: body.data,
    }),
);
if (!response.success) {
    // handle error (response.error)
    return;
}
if (!response.data.ok) {
    // handle network error
    return;
}
// handle response (body.data)
Enter fullscreen mode Exit fullscreen mode

So yes, our new solution is longer, but:

  • no try-catch
  • we handle each error where it occurs
  • we have a nice top-to-bottom logic, all errors on top, then only the response at the bottom

But now comes the ace. What will happen if we forget to check this one:

if (!body.success) {
    // handle error (body.error)
    return;
}
Enter fullscreen mode Exit fullscreen mode

The thing is... we can't. Yes, we MUST do that check; if we don't, then the body.data will not exist. LSP will remind us of it by throwing a "Property 'data' does not exist on type 'Safe'". And it's all Thanks to the simple Safe type we created. And it also works for error message; we don't have access to body.error until we check for !body.success.

Here is a moment we should really appreciate the TypeScript and how it changed the JavaScript world.

The same goes for:

if (!response.success) {
    // handle error (response.error)
    return;
}
Enter fullscreen mode Exit fullscreen mode

We cannot remove the !response.success check because otherwise the response.data will not exist.

Of course, our solution doesn't come without its problems, the biggest one is that you need to remember to wrap Promises / functions that can throw errors with our safe wrapper. This "we need to know" is a language limitation which we cannot overcome.

It may sound hard, but it isn't. You soon start to realize that almost all Promises you have in your code can throw errors, and the synchronous functions that can, you know about them, and there aren't so many of them.

Still, you might be asking, is it worth it? We think it is, and it's working perfectly in our team :). When you look at a bigger service file, with no try / catch anywhere, with every error handled where it appeared, with a nice flow of logic... it just looks nice.

Here is a real life usage (SvelteKit FormAction):

export const actions = {
    createEmail: async ({ locals, request }) => {
        const end = perf("CreateEmail");

        const form = await safe(request.formData());
        if (!form.success) {
            return fail(400, { error: form.error });
        }
        const schema = z
            .object({
                emailTo: z.string().email(),
                emailName: z.string().min(1),
                emailSubject: z.string().min(1),
                emailHtml: z.string().min(1),
            })
            .safeParse({
                emailTo: form.data.get("emailTo"),
                emailName: form.data.get("emailName"),
                emailSubject: form.data.get("emailSubject"),
                emailHtml: form.data.get("emailHtml"),
            });
        if (!schema.success) {
            console.error(schema.error.flatten());
            return fail(400, { form: schema.error.flatten().fieldErrors });
        }

        const metadata = createMetadata(URI_GRPC, locals.user.key)
        if (!metadata.success) {
            return fail(400, { error: metadata.error });
        }
        const response = await new Promise<Safe<Email__Output>>((res) => {
            usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
        });
        if (!response.success) {
            return fail(400, { error: response.error });
        }
        end();
        return {
            email: response.data,
        };
    },
} satisfies Actions;
Enter fullscreen mode Exit fullscreen mode

Few things to point out:

  • our custom function grpcSafe to help us with grpc callback
  • createMetadata return Safe inside, so we don't need to wrap it.
  • zod library is using the same pattern :) If we don't do schema.success check, we don't have access to schema.data.

Doesn't it look clean? So try it out! Maybe it will be great fit for you too :)

Also, I hope this article was interesting for you. I hope to create more of them to share my thoughts and ideas.

P.S. Looks similar?

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f
Enter fullscreen mode Exit fullscreen mode
const response = await safe(fetch("https://example.com"));
if (!response.success) {
    console.error(response.error);
    return;
}
// do something with the response.data
Enter fullscreen mode Exit fullscreen mode

P.P.S.

If you liked it, I would really appreciate it if you followed me on Twitter and shared it! I want to start sharing knowledge about lesser-known technologies, like gRPC, gradually streaming data into a page, etc. :)

Follow me on Twitter

Top comments (49)

Collapse
 
skyjur profile image
Ski • Edited

Wrapping JSON.stringify is exercise in futility. You cannot recover from this error. Then why even handle it? Fix the bug instead of handling bugs and adding workarounds. You only need to handle errors if you have decided on particular fallback logic for the specific error. If you don't have specific ideas for fallback then you shouldn't have try/catch for that part of code. You also shouldn't need specific error messages on every single function call as stacktrace should be enough for you to be able to debug it. You only need 1 high level try/catch for any kind of unexpected error that shows "sorry something went wrong' to user" and logging/stack-trace that is good enough to debug and fix your errors.

Collapse
 
buffos profile image
Kostas Oreopoulos

Sorry but you are wrong. Handling the error at the origin of very different than debugging through trace stack.

Network errors, db errors, validation errors, file system errors, do not need a stack trace.
They might need a retry strategy, you might need to respond differently.
An smtp server error could be handled by retrying, using an alternate server or using a message broker to retry in future.

A top level only error handling is very bad and sloppy.

Collapse
 
skyjur profile image
Ski

As you can see I am not really focusing on network errors but on JSON.stringify. The OP advocates blind try/catch on everything. Network errors sure need to be handled. Yet again probably not at every single case where fetch is used. Application should really only need one handler for network connection errors and feature/domain specific functionality should not be plagued with error handling for networking layer issues.

Thread Thread
 
buffos profile image
Kostas Oreopoulos • Edited

I strongly disagree. Even with Json stringify.

You get data from a database and fails with Json stringify. It should not under normal circumstances. A stack trace does not help, you need good logs,
I do not know if you have ever used the efk stack of something similar, but good logs are as important as stack traces

Your goal is not only to inform the user. Logs are your friend

Thread Thread
 
skyjur profile image
Ski • Edited

Sure good logs are helpful too. I didn't said they are not. Agree that logs are necessary. But good logs is a separate question. Adding a message to JSON.stringify failure as in "stringify failed", is not helpful at all. The error from JSON.stringify is already useful enough as it says Uncaught TypeError: Do not know how to serialize a BigInt. Also stacktrace points to line where JSON.stringify is. If you just don't do touch it and don't try to reinvent errors there is already quite enough information to know how to debug the problem. Now it becomes hard when you start wrapping this with your own error messages and hiding stacktraces. In OP's article the stack trace will be gone and instead of well known error you will get custom error, and the way he's done it - because he's trying to remove all exceptions and replace it with railway programming - stack trace might be so screwed or non existent that it will be many layers away from where the error actually happened. And it doesn't end here the way he's doing it there might be no error reported at all and he might just return 400 over api confusing the API where instead it is a applicatoin bug that only deserves 500 response and alerts triggered. When your code is so screwed like that then indeed only excessive logging can save you from spending days of debugging the problem but even with them it might be difficult.

Thread Thread
 
buffos profile image
Kostas Oreopoulos

Golang uses error wrapping, and unwrapping, and is very very effective.

Stack traces are for noon recoverable errors, mainly.

With errors as values you have
a. The ability to control if you recover or not
b. How to respond to the error.
c. If you wish to hide implementation details of not.

Generally, is good practice, each layer of your application to have it's own errors.
You can use error wrapping for some part of your app, for inner logging and some other errors for the end user to hide implementation details.

In any case,I really like the idea of errors as values.

Thread Thread
 
skyjur profile image
Ski • Edited

As long as you handling errors in way that is correct. Yet often errors are not handled in correct way. In case of JSON.stringify there is no correct way to handle it as correct way is to not get to this situation where this error happens in the first place. Yes each layer of application having it's own errors makes sense but only if you have clearly defined your architectural layers. If you don't have clearly defined what layers usually it's best to leave errors as is. Lastly any error that was unexpected should be propagated up ensuring stacktrace. And go lang is golang js is js. Each language has it's own quirks and patterns. If you start blindly applying patterns from different programming language in js you will get a hot mess. In other programming languages errors might not be happening for example due to types. For instance JSON.stringify in type safe language might accept only a certain type object that then guarantees that it never throws errors. So you won't have this problem in first place. Note that the error that comes out of JSON.stringify is TypeError. Usually if you get TypeError means you have made mistake writing code, it's just that JavaScript doesn't have static checks so all checks are only done in runtime. In the end every situation needs careful analysis. Idea of errors as values is quite limited. Propagating errors arrived for some good reasons. If you try to get rid of it you'll likely run your self into problems that had been solved some 30 years ago. It is possible to design language with errors as values instead of stack traces but javascript is not designed in this way. If you don't like javascript then you should not write applications on node.js in the first place. I would understand why you write browser application despite not liking javascript but writing node.js application in style of 'golang' why aren't you writing in golang in first place? Going back to my main point all this is reinvention of how one should write js is exercise in futility. You have to learn good tactics and approaches with every language. I can't say I like JavaScript and node.js approach but if I'm using node.js I'll use it the way it's been intended as opposed to attempting reinvent new coding paradigm within javascript - the later simply never works.

Thread Thread
 
terrapluviamcmu profile image
Rain

First, I remember Mateusz being very clear in the article that their example was targeting TypeScript and leveraging the type-inference and type-checking it provides. Take away the type system, then exceptions and error values would function alike; however, a type system makes it mandatory to handle error values and gives type hints for what errors may occur in the case of the Either monad, while generally refusing to do the same for exceptions (with notable outliers like Java, but not TypeScript). Additionally, "discriminated unions" is an established idiom in TypeScript and is featured in its beginner guides; there are also libraries dedicated to making error values in TypeScript more elegant and less of a "hot mess", such as the NPM package true-myth.

Secondly, I do agree that the specific case of try-catching BigInt as argument to JSON.stringify() isn't the best: we should just type-check our objects-to-be-serialized beforehand. TypeScript refuses to type-check the argument value (type any) to JSON.stringify(), but we can make our own, like this:

type JsonSerializable =
    string
    | number
    | boolean
    | null
    | JsonSerializable[]
    | { [key: string]: JsonSerializable };

JSON.stringify({a: 2n}); // no error
const o6: JsonSerializable = {a: 2n}; // TYPE ERROR!!!
Enter fullscreen mode Exit fullscreen mode

And as a final note, if you want to discuss a specific problem like JSON.stringify() in this comment thread, please be constructive and concrete, instead of rejecting others completely and turning the discussion into an argument loaded with unhedged judgements (e.g. "never something something") based on solely your personal experience, especially when the author has already said their style works for their team.

Thread Thread
 
skyjur profile image
Ski • Edited

Thanks for sharing very productive tip on how to add type-safety to JSON.stringify(). This indeed really good way to avoid such errors.

Regarding your point about style, software engineering is not a fashion show. Style does not override practical implications. Nor is this a religion - meaning - instead of sticking to personal beliefs of what we think is best way we instead can continue discussion until we figure out which approach is the best.

I presented very clear case why wrapping JSON.stringify() into result type is poor decision. Though this was not the only issue in post but this is easiest to discuss. Other problems included returning incorrect status codes (for example 400 in case of network error where 500 should be), or code that is left ambigous, such as:

const result = ..
if(!result.succes) {
   // handle error
   return
}
Enter fullscreen mode Exit fullscreen mode

I agree with you that Result types, with typed error codes, validated by typescript, can indeed be productive choice. But it does take a lot of effort to produce correct error codes for every situation. And then it also takes additional effort to map every error code in correct manner. This runs risk that some error code will be handled and mapped incorrectly. And JSON.stringify() is one example where there is no good way to handle it, if so, then why even bother producing typed error code for it that will only create a chance for developer to implement incorrect error handling? If unsure let it throw error, let it crash (500), log the error. Error codes with Result type checking should be reserved only for situations where it is unambiguous what one should do about the error. Handling error incorrectly is usually worse than not handling it at all and allowing it to be handled with a generic error handler that already works on assumption that error type is unknown.

Considering that I had not been presented with approach which is clearly better - instead what I am seeing is examples of code that is more complex than necessary, left ambiguous in important situations, and code example that on top of all contain bugs - I remain to be convinced that this style is exercise in futility. I will be more than happy to be proven otherwise.

Thread Thread
 
andrewtrefethen profile image
AndrewTrefethen • Edited

The reasons are simple. Result types force errors to be local-first. You must explicitly decide to hoist (return) the error to a higher scope. It makes it extremely easy to see, feel, understand when you are allowing errors to propagate too far up your call stack.

As for JSON.stringify, I wrap that specifically because I have instances where it happens to be in the middle of a series of steps that must all succeed or fail together. So forcing the error to be explicitly dealt with immediately after the call guarantees I know exactly which step failed and what I need to rollback / void. (Really important when you're interfacing with third-party APIs that can have real-world consequences). It does this while also allowing me to declare the result as const without opening an additional scope. Using try-catch, you either have to put everything beyond your to be guarded statement inside the try block or declare a variable as let outside the try block to hold the successful result. If you put everything in the try block, then you have to check your errors to make sure they were actually caused by the statement you intended to guard and not a later one, or you can wrap the following statements in yet another try catch and reinvent the pyramid of pain.

As for "not knowing what throws", that's easily dealt with if you're deligent and willing to configure a linter. I have safe verities of most throwing functions / APIs in JavaScript. I have configured linting rules that make wayward usage of the non-safe variants into errors.

Finally, there are actually performance improvements with using value-based errors instead of a regular try-catch. The fact that these safe varieties are singular throughout the codebase means they get warm much faster than having willy nilly try-catches everywhere. V8 also recognizes that the error can't propagate outside the function scope and so optimizes out the normal try-catch machinery in favor for simple test branches. Exceptions as values have objective improvements both ergonomically and performance-wise over the basic try-catch machinery.

Thread Thread
 
skyjur profile image
Ski • Edited

Of course exceptions are meant for exceptional situations and should be rare enough to not cause performance issue. Yet converting exceptions into result types normalizes exceptions as normal application flow. Which raises question can you not ensure that your application flow doesn't produce exception to begin with, so that, exception can be treated only for exceptional situation, so that they don't need to be treated like values?

Indeed if dealing with library that decided to throw exception where in application it's normal flow - it's good idea to search for alternative library - or if not possible - just write a wrapper around it that removes exception

const tryToJsonStringify = (value): string | null => {
   try{ return JSON.stringify(value) } 
   catch(e) { return null }  
}
Enter fullscreen mode Exit fullscreen mode

end result then of domain-specific code is as clean as it can be:

var result = tryToJsonStringify (value)
if(!value) {
   ...
}
Enter fullscreen mode Exit fullscreen mode

in contrast following OP's idea domain specific code is littered with additional brackets and additional "tryTo()" function call, so is harder to follow:

var result = tryTo(() => JSON.stringify(value))
if(!result) {
   ...
}
Enter fullscreen mode Exit fullscreen mode

performance wise, tryToStringifyJson() is definitely not going to be slower, because call stack is as small as it can be:

  1. tryTostringify()
  2. JSON.stringify()

meanwhile the generic tryTo() wrapper (idea from OP's post) call stack is deeper

  1. tryTo()
  2. anonymous closure
  3. JSON.stringify()

thus in case of exception, exception also need to be go through 1 more item on stack

the fact that tryTo is "warms up" as it's more abundant in codebase compared to tryToStringify() really doesn't matter because in any situation where it matters tryToStringify() approach will allso warm up, but with case of generic tryTo() that takes a callback - callback still needs to warm up.

Overall my advise is:

  • If "error" is part of your expected application try to express it in way that it does not even have "error" in it. If it is expected to happen, then it's not an error. Result types are good but even better could be type that doesn't even differentiate between "Yes/No". Consider case of Response. Typically libraries return Response object, with 'status' field denoting http status. 500 is same type of response as 200 - it's just a response. Exception is often thrown in case of network error but in most typical applications network errors are not part of typical application flow. Of course if the application in question is such as gateway then network error becomes part of normal flow. If application is to show list of users from database, then, application logic should express clearly what happens when everything works fine. When there is network problem with the database instead of code that deals with user-listing dealing with network-error, there should be separate application layer that is decoupled from user-listing that deals with networking state, and user-listing should be decoupled from networking state and should be able to assume net it has a working network connection.
  • If dealing with library that throws exceptions where preferred is result type, write a wrapper that converts library interface to interface that is more convenient, add try/catch as soon as interfering with library
  • avoid 1 size fits all ideas, focus on what achieves the best domain clarity in particular case, apply best patterns for the task at hand - in a nutshell learn situations where exceptions make sense, learn situations where result types work well, use both where best suited.
  • consider that maybe library author or language creator actually had pretty good reason why they throw error instead of giving you result type and maybe maybe you don't need to convert it, this is case where it just makes sense for majority of web applications for fetch() to throw error in case of network error. It also makes sense for JSON.stringify to throw error if you asked to stringify invalid value because chances are if you try to ignore this error you will hide more important bug in you application that later you won't be able to find easily as there will be little clue as to what has failed
Thread Thread
 
andrewtrefethen profile image
AndrewTrefethen • Edited

You seem to have misunderstood several points.

  const tryToJsonStringify = (value): string | null => {
     try{ return JSON.stringify(value) } 
     catch(e) { return null }  
  }
Enter fullscreen mode Exit fullscreen mode

This is pretty much exactly what I referred to when I mentioned that I use "safe" varieties of functions that throw and that those functions have both ergonomic and performance wins. The differences between what you wrote and what we use are non-trivial though.

  const safeStringify = (input: any): Result<string> => {
      try{
          return ok(JSON.stringify(input));
      } catch(e) {
          return err(e)
      }
  }
Enter fullscreen mode Exit fullscreen mode

The biggest difference is that ours doesn't use a special value to represent an error condition. That is considered bad practice and should be avoided. It is the reason that newer APIs added to javascript have opted INTO throwing exceptions or calling an "onError" callback now. Special values cause havoc in the rest of the application specifically because they can be ignored; or worse yet, undocumented. Devs that don't anticipate a failure condition will neglect to check for the special value, possibly allowing a NULL to enter into your application, causing further errors and possible data corruption. This isn't hyperbole either, these types of errors regularly occur even in codebases at the likes of Google, Apple, Microsoft, etc. Our approach makes the failure or passage explicit and FORCES you to anticipate the possibility of an exception and either handle it, or EXPLICITLY let it bubble up your call stack. I still Throw certain errors when something happens that should force our application to blow up and restart, but I make that choice EXPLICITLY and never by accidentally forgetting to capture a potential error or handle a special value. It also forces errors to be local-first. I HAVE to interrogate the return value to determine if an exception occurred in order to get the result, so no more "I'll just code the happy path today and instrument my exception handling later". This has been a HUGE quality of life improvement when onboarding new developers into our codebases. Not only is it explicit which functions can produce errors, but those errors HAVE to be dealt with. Linting rules ensure that every Throw is cautioned so it can be looked at during code review and the dev better have a really good reason for just throwing.

By taking this approach, we have reduced the time required for new devs to be productive, reduced the number of bugs introduced to our codebase substantially (unhandled exceptions and coding for the happy path are all-too common, not to mention that people can forget that things like JSON.stringify can throw), and increased the clarity of our codebase markedly. It is significantly clearer when we are choosing to deal with exceptions and when we are choosing to let them blow up the runtime. It is clearer when and how to rollback as you have full context. Take a small sample of our codebase for example:

  const newClientResult = models.Users.create(...);
  if (newClientResult.isErr()) return err('Failed to create client', newClientResult);
  const newClient = newClientResult.unwrap();
  const QBClientResult = QB.createClient(newClient.toQBObject());
  if (QBClientResult.isErr()) return err('Failed to create QuickBook Customer Profile, please check back in a few minutes, QBClientResult);
  newClient.markAsReady();
Enter fullscreen mode Exit fullscreen mode

It is explicit which operations can fail and which cannot. The error handling is near where the error occured, meaning you have all the context necessary to understand what has failed. Operations that can fail must be checked before using their value. In cases of error, the dev will need to make a choice as to how to respond to the error.

You might be thinking, well why would you force yourself to use these functions and incur the overhead of wrapping the returns in an object for guarded statements that you know can't fail, and the answer is we don't. We have versions of some functions that are guaranteed to not fail provided the types of the inputs are respected. For example, we have a version of JSON.stringify whose parameter is strongly typed as a regular old javascript object, a number, a string, a boolean, an array, objects with a toJson function that returns a type matching those requirements and combinations thereof. If the function accepts the input (and you haven't pulled any typescript shenanigans), then you're guaranteed to get a proper result and so it isn't wrapped in a Result type. Very useful when you use a validation library at the boundary to your application and can guarantee the types internally.

Errors as values increases the clarity of code, it reduces the frequency of errors, speeds up the onboarding and mitigates code quality regressions of new devs, and even has a few performance wins to boot. Try catch has its place, but errors as values is objectively better on all the metrics me and my company care about.

Thread Thread
 
skyjur profile image
Ski

Thanks for extensive walkthrough and sharing your experience working within it. I can see that you thought about some important aspects like ensuring error stack trace by passing parent error as argument to err() which I believe allows in your system logging errors with great deal of clarity.

You pointed very valid problem in my example about return values. Indeed your solution solves this in elegant way as it ensures error stack. If returning null from JSON.stringify is ought to produce a failure that needs to bubble up then indeed this is a bad use-case for such return type and your solution with Result type works much better.

I'm not convinced about your claim for performance improvement. I can see how it can be slightly more performant in case of error branches. But in most common success flow you have overhead of wrapping every value with result object and overhead of skipping error branches on almost every function call. That said this is high level code so I don't believe that overhead in either way is affecting performance in any way. And you have mentioned you also make good use of functions that are guaranteed to not produce errors and I assume these implement the bulk of lower level logic that carries the bulk of performance.

Overall I am convinced with your arguments that when done right Result types can be improvement over regular exception mechanism - especially so for high level code. For low level code - error-free functions should be the way as only this guarantees really good performance for cases where it's important to process for large amounts of objects.

Collapse
 
mpiorowski profile image
Mateusz Piórowski

Pls remember that this is just an example, and ofc it doesn't show the whole picture. But just as simple real use case, what if we have an open api, and we json.stringify any data, now i can have a custom fallback right after the code (to info user that he is inputing data that cannot be stringify, and give him available options?). But still, it's just an example. But Your second argument, "You only need 1 high level try/catch". I get that this is ok for some ppl, I just prefer my way. And it's not only about having every error have a fallback case, it's also about small things, like reading the code top to bottom and seeing what can go wrong and where. And the multi-level funstions, and passing the objects from bottom to top is just much more fluid for us. But ofc this is only our opinion :)

Collapse
 
skyjur profile image
Ski • Edited

Your examples just make no sense. You are making code complicated for no good reason. Maybe you can try to work on better examples that delivery your message more clearly because what you did just make no sense. "I just prefer my way" makes no sense considering that result is less readable without clearly expressed benefits. Also note that personal preferences is never an argument when we're discussing software architecture it is a lazy escape that gets away from analysing situation more deeply what is essentially necessary to understand the better approach.

what if we have an open api, and we json.stringify any data

This makes no sense. Can you explain the particular setup? I can understand how you could use JSON.parse on untyped dataset, but JSON.stringify - this makes very little sense. Internal javascript objects are never direct input of user as user does not have ability to create javascript objects you probably used deserializer that converted user's input into internal objects and now you are using serializer to convert it to output (json.stringify). If we really talking about unchecked user input creating arbitrary javascript objects we're talking here about very clear remote code execution vulnerability. Of course your deserialization and serialization logic needs to be aligned and you should have unit test that covers all scenarios. It is also little strange to write an article about what looks like a generic advice but then pick a very very rare use case as illustrative point.

Thread Thread
 
mpiorowski profile image
Mateusz Piórowski • Edited

I would like to not drag this topic, so I will try to answer all Your points and i hope You won't be offended with no reply later on.

  • "result is less readable without clearly expressed benefits" - This is a very subjective point of view because, for us, the code is already more readable and has more benefits. I tried pointing out what brought us to this conclusion, such as identifying potential errors and ensuring the we check for errors before we can access the data, etc. However, I can understand that not everyone shares that view.

  • "Also note that personal preferences is never an argument when we're discussing software architecture it is a lazy escape" - Here, I simply don't agree. What I've learned throughout my years of coding is that there is never a single, golden solution, or one way of coding. Personal preferences influence that as well. Some people prefer coding in Rust and believe that it is the best language ever, while others love coding in Java and believe the same, even though the languages and their approaches to coding are very different. Personal preference.

  • As for the JSON.stringify, here You are right, i didn't think of it, when i fastly wrote my response to your comment. The open api is a bad example, but i can modify it a little to make it a (very uncommon, but possible) real life usage. Let's say we have an object facorty, that can create any object, and we just load it and stringify it. Now here we can have a fallback message, that the created object is not possible to stringidy. Ofc we can do it diffrently, by forbiding creation of object using some validation, but this is a posibility to do that this way. This ofc sounds like a stretch, but that also wasn't a point of the first examples, the main goal of the first ones was to illustrate the general idea, while the 'SvelteKit' example demonstrates how this translates into real usage.

Still, even though You dissagree, I hope You enyojed the article, and I thank You for Your input :)

Thread Thread
 
skyjur profile image
Ski • Edited

Problem with your examples and your article is that you didn't made convincing effort to explain why added branches in code are necessary. If it is unnecessary then you simply added complexity without explaining reason for it which is never a positive outcome. I do not argue that Rust is bad language. I am only analysing code examples that you gave and you took totally decent peace of code and turned it into bigger rubbish than it was.

What is lacking in your article is giving a valid reason why handling different errors is necessary and how application logic changes based on different error. But then also error handling is mixed up with code without any attempt to separate it thus error handling strategy is missing. You also attaching messages to errors again without clearly explaining why they are useful which would only increase bundle size without giving any user value.

const request = { name: "test", value: 2n };
const body = safe(
    () => JSON.stringify(request),
    "Failed to serialize request",
);
if (!body.success) {
    // handle error (body.error)
    return;
}
const response = await safe(
    fetch("https://example.com", {
        method: "POST",
        body: body.data,
    }),
);
if (!response.success) {
    // handle error (response.error)
    return;
}
if (!response.data.ok) {
    // handle network error
    return;
}
// handle response (body.data)
Enter fullscreen mode Exit fullscreen mode

question here is, for example, how is

 // handle error (body.error)
Enter fullscreen mode Exit fullscreen mode

different from

 // handle error (response.error)
Enter fullscreen mode Exit fullscreen mode

I can potentially see approach where you might want to handle network error to detect when you got offline. But this is missing in explanation. However, this can be achieved more elegantly that what you have with

try {
  ...
} catch(e) {
   if(isNetworkError(e)) {  
        return isOffline()
   }
   throw e
}
Enter fullscreen mode Exit fullscreen mode

and I don't think you need any other error handling in this case. If server returns 500 or if JSON.stringify fails, I very doubt that you need a different pathways and different error messages to end user. If you do need it then sure it's a different story but I doubt you do. You didn't made convincing evidence that you do need to recover from errors in unique ways.

Unknown errors typically should be rethrown so that stacktrace can survive. If you don't rethrow this is how debugging get's difficult as you lose stack trace. Then you need to rely on your custom messages.

A higher-level error handle can catch it, log it, display generic message to user. Stack trace should be good enough to provide debugging information. Attaching error message to every potential error as in your example is just very poor practice.

Your util of wrapping try/catch with callback is just ugly and indicates lack of error handling strategy in your application.

You need to have architecture and a strategy how you deal with errors such that you do not need to attempt to catch error that can potentially be thrown in every function call. You also need to separate error handling from your regular application code. This is all basic software engineering practices that you can find repeated in very many different books.

Error handling strategies might be very different depending on what kind of applications you're building. If you're building a database system it is one thing. If you have end-user application it is very different thing. In database system you do need to treat every case of error as actual application flow as there can be no tolerance for any kind of errors. For high level end user application where so many different things can potentially go wrong - unstable network, unstable backend apis, code that may fail due to bugs - typically you do not need to react to every case of error in unique way. Usually 1 fail path should be enough no matter where the error happens. And stacktrace together with some contextual information will already provide you enough debugging info without having to add messages to every function call that can potentially fail. You need to decide in your application's architecture where your error handling layer is and what do you do in this layer. And architecture for end user UI application is going to be quite different from architecture of internet gateway or database application.

Thread Thread
 
skyjur profile image
Ski • Edited

In context of high level end user applications (some other applications might need different coding practices, but such applications won't typically be written in JavaScript) I can guarantee to you your "preferences" that I see in your article are quite utterly garbage and creating utterly messy application architecture. But it will take very long time talking until you will figure it out. Yet you prefer not to talk about it and roll with idea that "every point of view is valid".

If you do use JavaScript for database systems or internet gateways or similar systems then garbage was the call to pick JavaScript for such task as nothing about it is good for such applications starting from the way garbage collactions in vm is implemented going all the way to choices done in the language it self.

Thread Thread
 
mpiorowski profile image
Mateusz Piórowski

I would even consider continuing this discussion, but your tone is rude and insulting. Instead of that, here is some advice on how you can share your opinion without sounding like a dickhead:

  • I am only analysing code examples that you gave and you took totally decent peace of code and turned it into bigger rubbish than it was -> While the original code seemed quite decent, the changes made appear to have increased its complexity significantly. I believe there might be room for improvement in terms of code optimization.

  • I can guarantee to you your "preferences" that I see in your article are quite utterly garbage and creating utterly messy application architecture -> I can see some preferences in your article that, in my opinion, may not align well with effective application architecture principles.

Constructive criticism is a key to success. Have a good day :)

Thread Thread
 
skyjur profile image
Ski • Edited

I was not personal. You are. Nothing remarkably wrong about saying that you made a peace of rubbish based on rather valid metrics that I presented and you didn't attempt to justify. I had experimented with very similar idea of "safe" wrappers as well as railway programming in JS few years ago and the only way I can describe my result is rubbish. If you take it personally and you think I am dickhead because of it then it is you who is mean not me. That said people who write off rather poor engineering choices as "personal preference" do often make me slightly angry and this did effect my tone to some extent I apologize for that. I do not believe that we should ever do anything based on "personal preference" and instead have strong justification that is based on provable concepts and if you don't have one you could as well just say nothing as opposed to saying "that's my personal preference". Problem when you say that you have a "preference" is that you already made a decision thus it is pointless to even discuss anything.

Thread Thread
 
chiroro_jr profile image
Dennis

Relax jeez

Collapse
 
cmcnicholas profile image
Craig McNicholas

Completely untrue, good architecture doesn't rely on stack traces into your library or application for end users (either developers or non-devs). Having well known errors, categories, codes etc. That represent a specific point of failure is how you scale projects sensibly to handle resiliency, support and self serving customers to understand what they did wrong.

Forcing you to think about errors and forcing you to handle them is a good thing.

Collapse
 
skyjur profile image
Ski • Edited

Error codes are for known errors. You would need a code for unknown error and you need some debugging information when that happens. Stack traces are for programming mistakes that shouldn't be there such as when you pass unserializable value to json.stringify(). Of course one should do what is possible to avoid making such mistakes. But if it does happen - you will not have error code - as every mistake in code is not something you can foresee and add error code - nor should you be wasting effort and adding error codes for buggy application branches.

Thread Thread
 
pfernandom profile image
Pedro F Marquez

I can tell you have never worked in any large application (or maybe with other developers at all, considering the rudeness of your comments).

Leaving aside that try-catch blocks can be overly wide (one big catch for a while service call), stack information can be obfuscated by variable minification, broken source-maps, multithreading, and calls from libraries or Frameworks, thinking that "just rethrow the exception" is a valid error handling strategy is plain short-sighted.

Blindly rethrowing an exception may be acceptable for toy projects, but in real-life production applications, errors need to be handled or at least contained.

When the exception is thrown, you have all you need already in the stack without it having to be propagated up the stack. If the error cannot be handled by the immediate function (like the stringify example you keep ranting about), functions up the stack won't fare any better.
What's worst, you're spilling implemention details out of the problematic function (you may not own the code that actually called your function). Just log the damn stack where it's caught and fail gracefully (or throw a new error that's actually useful in the context of the calling function, not just "TypeError: Do not know how to serialize a BigInt" which means nothing outside of the context of the original function).

Having said that, there is absolutely nothing in JavaScript (nor in Typescript) forcing you to wrap code that may fail in a try-catch block -Java at least tries to work around this by forcing you to handle or rethrow checked exceptions-. If you have junior devs in your team, they may forget to add error handling.
However, using the approach the OP presents, Typescript will force you to handle the error, no way around it (yes you can handle it poorly, but at least you're made aware of that, and that's easier to catch on code reviews).

Treating errors as values is not a fits-all-scenarios solution (as neither is try-catch) and you still need to support try-catch at some level, as not all errors can be found during coding (rust and go still panic on run time), but forcing devs to at least acknowledge that an error can exist improves code quality by a lot.

And if you're thinking "then your solution is to only hire good developers that know when to add try-catch blocks correctly 100% percent of the time", well, that's just another signal that you're just not really used to work in real-life projects ;)

Thread Thread
 
skyjur profile image
Ski • Edited

I would appreciate that you would not make up statements about me as you do not know anything. This is in fact much more rude compared to anything I said. Please learn a different between personal and impersonal attacks.

Back to the question. I did not say that result types are bad idea. I said that converting errors that shouldn't be happening on first place to error types is bad idea.

Typescript ultimately cannot get you write bug free application.

There are many situations that in case that you do have a bug in your app it's better to left it unhandled. Reason is because such bug will be faster to notice and faster to fix. When application is full of blindly handling all errors there is bigger risk that important bugs will be hidden and converted into flows that break apart only multiple steps away from source at which point it make take weeks to figure that out. And JSON.serislize was very very clear example of that.

Thing like network error may be considered as as error type on frontend app because it's important to create offline experience. Yet in backend application it is not always clear of anything should be done about network failures. OPs examples on network errors are handled incorrectly returning 400 to user - this is worse than leaving these errors up unhandled as does not produce error stacks and on top it returns incorrect errot code to API user leaving them misguided.

As for your comment on errors not having readable stack traces. This of course should be considered a big problem in your applications infra and architecture if stack traces come out mangled. This can be fixed. I'd suggest that teas probably shouldn't use typescript or any other transpiler unless they have resources to maintain tooling that is necessary to work with it. Every high level programming language has very decent support for tracebacks as this is universaly considered as important productivity tool. If you remove that you are back to C. Whilst it may make sense to wrote low level apps in C it doesn't make sense to wrote high level apps.

Thread Thread
 
pfernandom profile image
Pedro F Marquez

The difference between personal and impersonal "attacks" is that personal attacks consist of making negative comments about a person characteristics, most of the time things that cannot be changed like a physical condition, or personal preferences -commonly something that cannot be changed-. In this case, lack of experience experience is neither negative nor something that cannot be improved on :)

Yes, there are errors that shouldn't be happening, but in practice they do, specially when you don't fully control the data entering your services. It's naive to think otherwise.

Unless there is a very specific reason to do so, it's almost never a good reason to leave an error unhandled:

First, more often that not, that could lead to break the whole app (especially if you are also applying this idea of not handling errors up the stack, or if you don't own those higher-level functions); and breaking the app is not an option in a production -level app.

Second, like I mentioned before, at the point where the exception is caught, you already have all the information you need between the stack trace and the function context. You can send this to a logger service and return a simpler error up the stack, something that is actually useful, without spilling implemention details like variable names, or ridiculously long stack traces.

The OP never said the examples were specifically for backend code. This is an assumption you chose to make. All the examples fit in the context of the frontend side of the stack, where mangled and verbose stack traces is not uncommon.

Also, you chose to qualify the OPs code as "utter garbage" just because he chose to include simple -but maybe impractical- examples to display how errors as values work. It's like judging a programming example for using "foo" or "bar" as variable names: Yes, no one would actually do that in prod, it's only for demonstration purposes, and it missed the point if the example.

And no one said Typescript will enforce a bug-free application. The pattern displayed in this post combined with Typescript is one tool to give developers to enforce error handling that may get them closer to that goal.

You may disagree with the content of the post, or find that it's impractical for your use case, but that doesn't make it "garbage".

Thread Thread
 
skyjur profile image
Ski

If you read what I say I argue that application needs to always have a high level error handling that would recover from errors in some way. For example in server application it would be 500 response - as opposed to server crashing. And in client application it could be error boundaries covering individual UI modules. Crashing application in case of bug - in controlled manner - is much better than hiding bugs behind and misguiding users. In the end of the day if you hide error the application still doesn't do what it should be doing - same as if crashing - but it makes users and also developers left confused as to what is happening. And regardless of what patterns you use - using error result - or not - in JavaScript code base you need error boundaries regardless because fundamentally javascript is not safe. Once you have error boundaries it is rather pointless to additionally wrap code that potentially could fail - such as JSON.stringify - as instead effort should be focused towards preventing application getting into errornious state in first place.

As for your idea that such code is not being written by anyone in prod unfortunately my experience is quite different as I've seen countless number of times when an actual bug is wrapped with try/catch to hide an error yet the bug is still there.

Collapse
 
sevapp profile image
Vsevolod

Great article and good examples! I would also add a recommendation to design functions initially in such a way that the list of possible returned errors is clear. For example, through the Either monad.

Collapse
 
mpiorowski profile image
Mateusz Piórowski

You made me discover the Either monad :) Just another example that you never stop learning.

Collapse
 
hinogi profile image
Stefan Schneider

here a common lib for ts and Either

gcanti.github.io/fp-ts/modules/Eit...

Collapse
 
sevapp profile image
Vsevolod • Edited

It's amazing! I think Either would be a great addition to your work. safe T for wrappers of already existing unsafe code and Either L, R for implementing safe code natively

Thread Thread
 
mpiorowski profile image
Mateusz Piórowski

More i dive into it, the more interesting it looks like, but this is like a material for another article :D It's not just a "few lines" more to make it work and make it understandable ;p

Thread Thread
 
sevapp profile image
Vsevolod

I've spent quite a bit of time thinking about and implementing these monads in TypeScript in a way that's really pretty and understandable. If you want, I will be glad to share and work on the article with you)

Thread Thread
 
mpiorowski profile image
Mateusz Piórowski

Yeah, that's sounds rly interesting. First i need to dive into this topic and see if this will suit me. But if this will be as interesting as it seems to be, i would glady make another article with Your help.

Collapse
 
hinogi profile image
Stefan Schneider

You can also throw in some pattern matching

dev.to/gvergnaud/bringing-pattern-...

Collapse
 
mpiorowski profile image
Mateusz Piórowski

Yeah, i knew about that one, I would really like to see this implemented and not rely on external libraries... at least js is trying to impove, we will see where it will take us :)

Collapse
 
hinogi profile image
Stefan Schneider

Also, good old James Sinclair handled that already in

jrsinclair.com/articles/2019/elega...

Collapse
 
rob117 profile image
Rob Sherling

Absolutely fantastic article. Interesting way to handle errors - the need to remember to wrap promises in safe is rough, but the tradeoff gets rid of big try-catch blocks and messy error handling. And the Either monad comments are educational as well.

10/10 quality post.

Collapse
 
jsardev profile image
Jakub Sarnowski

Amazing article, thank you! :)

Collapse
 
lzzqwe profile image
liudehua

isExternal

Collapse
 
skyjur profile image
Ski

I see in many cases of your code you are deciding to return error code 400. Yet in many cases I see that no user-error can produce it and they do seem more like programmer/server errors that should result in 500. And if they do result in 500 then you should consider to return generic error message, and log the error, to avoid exposing application internals over api.

Collapse
 
milaabl profile image
milaabl

Why not use a package like true-myth? Particularly for fetching and API integrations, @zodios/react, @zodios/core & zod would work best together for type-safe error handling & return types handling & validation.

Collapse
 
mickmister profile image
Michael Kochell • Edited

So safeAsync takes a promise. What if the function that was called to get the promise throws an error, before it gets to its return statement that returns the initial promise? Then the exception would happen before safeAsync has a chance to do its error catching.

Collapse
 
robertsandiford profile image
RobertSandiford

It would be pretty easy to deal with exceptions through typed errors. Functions show be marked with the type of error they throw, and a function that can throw should be colored in IDEs so we know about it. Most of it this would be automatically inferred.

But this is something that would need to be added to TS, so monads are a good option for those just using TS

Collapse
 
marcio199226 profile image
oskar

nice but I prefer github.com/scopsy/await-to-js approach is more straighforward though

Collapse
 
mpiorowski profile image
Mateusz Piórowski

True, the general idea is almost the same, they have a wrapper that return a tuple with err / data. But for me i prefer the "forced" approched, which, if i am correct, they do not implement. You can access the response without checking the error.

Collapse
 
marcio199226 profile image
oskar

"You can access the response without checking the error."
Can you explain better what you mean?
Unfortunately we can't ignore return values in js such us in go by using "_" as var's name...

Thread Thread
 
mpiorowski profile image
Mateusz Piórowski

So looking at the example in the repo, You can do sth like that:

[ err, user ] = await to(UserModel.findById(1));
doSomethingWithUser(user); <- You can do anything with user, without checking the error
Enter fullscreen mode Exit fullscreen mode

Looking at my example:

const response = await safe(fetch("https://example.com"));
doSomethingWithResponseData(response.data); <- i cannot do this, the lsp wont let me, not without checking the error first
Enter fullscreen mode Exit fullscreen mode

But maybe their lib works diffrently, i didnt dive deep, just looking at example so i could be wrong.

Collapse
 
vitalicset profile image
Vitali Haradkou

Good article, such JS error handling misunderstanding motivates me to write Rust-like library, since it's just better on a mile.
github.com/vitalics/rslike

Some comments may only be visible to logged-in visitors. Sign in to view all comments.