Skip to content

Talk about Exceptions Here #56365

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

Open
1 task done
RyanCavanaugh opened this issue Nov 10, 2023 · 106 comments
Open
1 task done

Talk about Exceptions Here #56365

RyanCavanaugh opened this issue Nov 10, 2023 · 106 comments
Labels
Discussion Issues which may not have code impact

Comments

@RyanCavanaugh
Copy link
Member

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

#13219 is locked so that the conclusion doesn't get lost in the discussion, so talk about exceptions here instead

@RyanCavanaugh RyanCavanaugh added the Discussion Issues which may not have code impact label Nov 10, 2023
@fatcerberus
Copy link

I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

I love the irony that a maintainer was forced to check this box 😄

@michaelangeloio
Copy link

#13219 (comment)

@KashubaK @kvenn I'm going to start looking at intellij (and also see if I can make an eslint plugin for it!). If you want to help, let me know!

@kvenn
Copy link

kvenn commented Nov 27, 2023

Would be happy to be involved. I did like the idea of ESLint plugin because it's piggybacking off of an already established static analysis solution, but I think IDE plugins also check that box.

This comment has some code for the starting point of the ESLint plugin (in a collapsed text block): #13219 (comment)

@michaelangeloio
Copy link

Would be happy to be involved. I did like the idea of ESLint plugin because it's piggybacking off of an already established static analysis solution, but I think IDE plugins also check that box.

This comment has some code for the starting point of the ESLint plugin (in a collapsed text block): #13219 (comment)

@kvenn see my comment here about eslint michaelangeloio/does-it-throw#70 (comment)

I've got jetbrains-intellij working now, but waiting on jetbrains to approve it! You can check the code for that here if you'd like: https://github.com/michaelangeloio/does-it-throw/tree/main/jetbrains

@kvenn
Copy link

kvenn commented Jan 7, 2024

Heck yes! I'll happily use the IntelliJ plugin. I'll check back in a bit and install when it's approved.

Shame that eslint doesn't support async and that it's a ways away. But there's even more you can do with a plugin.

Nicely done!

@arivera-xealth
Copy link

@kvenn jetbrains is now available! https://plugins.jetbrains.com/plugin/23434-does-it-throw-

Feel free to share with others!

@kvenn
Copy link

kvenn commented Jan 29, 2024

I've since gotten the opportunity to try out the JetBrains plugin for does-it-throw and after looking into it a bit more, I don't think that really solves the problem I'm having with exceptions.

That plugin seems to mostly be about alerting of where throw statements are used. Which appears to be for enforcing that you don't use throw statements. I think throw statements are here to stay, even if I agree first class support for errors has advantages. And that if you're already in a codebase which relies on throws, this adds a lot of noise.

I had proposed an ESLint exception to warn when invoking a function that can throw. Encouraging you to either mark(document) this function as one that re-throws or to catch it. With the intention being to prevent you from accidentally having a function that throws bubble up all the way to the top of your program. But allowing that to be the case if it makes sense (like in a GraphQL resolver, where the only way to notify Apollo of the error is via throwing, or a cloud function / queue where throwing is used to retry).

If it can be found implicitly (without documentation), that's better. And it seems like a plugin could actually achieve that (and even offer quick fixes, which would be SO COOL). I'd advocate for using an already used standard TSDoc annotation (@throws) as the acknowledgement that this throw statement is there on purpose (as opposed to introducing a new one - @it-throws or @does-it-throw-ignore).

does-it-throw has some great bones. And it seems like it's solving a problem for others, it just might not be the right fit for me.

@Kashuab
Copy link

Kashuab commented Jan 29, 2024

@kvenn I've tried my hand at an eslint plugin: https://github.com/Kashuab/eslint-plugin-checked-exceptions/tree/main

It introduces two rules:

  • uncaught-errors

Checks to see if a function you're calling has a @throws JSDoc annotation. If you don't wrap the function call in a try/catch it will output an error.

  • undocumented-errors

Warns you if a function has a throw statement without a corresponding @throws annotation. Matches based on what you're throwing, in case you have custom error classes.

Check out the tests for examples and what it covers. It's been a while since I looked at this, but I remember it being a bit buggy (i.e. nested branches, complicated logic) so there's a ton of room for improvement. I might take another look to improve it.

Side note - the README suggests you can install it from NPM, this is not the case haha.

(This is my work GH account, dunno why I have a separate one but oh well. I previously contributed here as @KashubaK)

@wiredmatt
Copy link

wiredmatt commented Mar 2, 2024

I'm definitely a complete noob when it comes to how Javascript/Typescript works, but would it be possible to add the modifier throws to a Typescript function signature?

https://docs.oracle.com/javase/tutorial/essential/exceptions/declaring.html

As both a Typescript user and a library maintainer, I hate not being able to consume / deliver proper error declarations. I know I can use JSDoc's @throws, but having that keyword as part of the function signature would be so great...

@KashubaK
Copy link

KashubaK commented Mar 2, 2024

@wiredmatt That suggestion was discussed in detail in the linked issue: #13219

The TL;DR is essentially, it's not worth doing because there isn't sufficient existing practice/documentation/runtime behavior to facilitate such a feature. TypeScript is designed to fit within the scope of JS' behavior, and since JavaScript doesn't give us reliable, native tools for things like checked exceptions it's challenging to fit it within scope.

What we're pondering now is, what's the next best thing? How can we encourage better error handling practices enough that the community has some common ground to operate on?

@wiredmatt
Copy link

@KashubaK thank you for your response.

I was thinking about the newly introduced type guards / type predicates, in my mind it seemed totally possible, especially knowing we have conditional types as well.

I'll keep an eye on the eslint solution, that makes sense to me knowing what you just explained. thanks!

@mharj
Copy link

mharj commented Mar 4, 2024

I was thinking about actual "throws" keyword as optional in return type, so TS would automatically add defaults to current functions/methods .. something like throws<any> or throws<unknown> at least for starting point.
so when we do write modules we can actually more strictly expose what type of things we are throwing out like example.

function doAuth(): AuthPayload throws<TypeError | AuthError> {}

and maybe have some utility type similar as typeof to extract errors types from function so we can more easily utilize other modules throw types without directly importing those.

function someStuff(): AuthPayload throws<throwof doAuth | FatalError> {}

Also maybe this have later impact on catch argument type to actually know throw types, but just actual documentation of throw types is way more important atm for interoperability between modules as currently we are just quessing and reading module source code to undestand what might actually get thrown.

Edit:
this would also work for indication that function will never throw .. throws<never>

@RyanCavanaugh
Copy link
Member Author

Also maybe this have later impact on catch argument type to actually know throw types

This doesn't really work unless you have a level of information that doesn't exist in the real world, and requires the ability to express many patterns that are basically arbitrarily complicated. See #13219 (comment)

@thw0rted
Copy link

thw0rted commented Mar 4, 2024

but just actual documentation of throw types is way more important atm for interoperability between modules

If documentation is the issue, you don't need a TS keyword -- https://jsdoc.app/tags-throws has existed for ages. I don't know about you, but I really don't see it used very often. This is the heart of the problem Ryan described in the comment linked above (summarizing the original issue): JS developers don't, broadly speaking, document expected exception behavior, so there's a chicken and egg problem where trying to implement checked-exception types would go against the grain of the current ecosystem.

Use of the @throws JSDoc tag can be treated as a sort of demand signal for better exception handling. Poor @throws adoption indicates that the community doesn't want it. And without good @throws coverage, a throws keyword in TS wouldn't be very useful in the best scenario, and would be actively misleading at worst, giving devs the impression that they've handled the "expected" throw scenarios when they haven't.

All that said, I still think there could be a place for some limited ability to perform static analysis of exception/rejection handling. I originally found the previous issue when I enabled a linter rule that looks for unhandled Promise rejections, which overlaps with try/catch once await enters the picture. I was looking for a way to decorate some async function calls as being unable to throw or reject (think return Promise.resolve('static value')), which would let me build out exception-safety from the bottom up, slowly. Maybe this could work if we split the feature into declaring or asserting throws-type (basically the @throws JSDoc tag), with a separate keyword or directive for enabling exception checking:

/** unsafe-assertion-that-this-throws-strings */
function throwsSometimes(): number {
  if (Math.random() < 0.5) { throw 'nope!'; }
  return (Math.random() < 0.5) ? 0 : 1;
}

/** unsafe-assertion-that-this-throws-never */
function throwsNever(): number { return JSON.parse('2'); }

/** checked-assertion-that-this-throws-never */
function maybeSafe(): number {
  return throwsSometimes() || throwsNever(); // error, unhandled throws-strings does not match declared throws-never
}

Note that this is a different scope from what was discussed in the checked-exceptions section of #13219 (comment). I'm trying to statically analyze that explicit/declared throws propagate correctly up the chain, and importantly, to limit where those checks are performed. I want to be able to decorate one function as not calling functions with decorated/expected exceptions outside of a try block -- to annotate one function as "exception-prone" and another as "bad at exception handling". I think there's value in that even if most library functions I call don't (currently) have their expected exceptions documented. (In Ryan's terminology, this requires "option one", unannotated functions are assumed not to throw anything.)

@kvenn
Copy link

kvenn commented Mar 4, 2024

Poor @throws adoption indicates that the community doesn't want it.

I don't know if this is true. Annotating with @throws doesn't actually enforce anything. So its value is only to document and therefore solves a different problem than checked exceptions (prevent unhandled exceptions). For those that document their code, I've found it very common to use @throws. But people aren't going out of their way to annotate.

A static analysis solution does seem to be the best. And leveraging @throws (for those who do use it) feels like a natural solution. And I agree if it's omitted, it's assumed it doesn't throw.
But it would also be easy to have a linter tell you you're missing the annotation of a function that has a "throw" in its body (or a function it calls) - for those that do care.

@KashubaK
Copy link

KashubaK commented Mar 4, 2024

How about instead of all this, in your code you just return an Error, instead of throwing altogether. This is more reliable, easily supported by runtime behavior, requires less syntax to handle, already works in TypeScript, and wouldn't require massive refactoring if adopting a Result return type paradigm.

function wow(value: unknown) {
  if (typeof value === 'string') return new StringNotSupportedError();
  
  return { value: 1234 };
}

const result = wow('haha');

// @ts-expect-error
result.value; // TS error, you have to narrow the type

if (result instanceof Error) {
  // Handle the error
  return;
}

console.log(result.value); // Good!

It forces you to handle errors. Seems pretty similar to what people are asking for. I know that actually throwing behaves differently, but I wonder actually how much this would suffice.

I find that the more I think about this, the more I care about it only in my application source code. I'm not all that worried about third party libraries. I don't think there's been a single time where I wished a library had an error documented. Usually good type definitions avoid runtime errors that are worth catching. I also wonder if errors are even suitable for the things I have in mind. Things like validation contain useful state that don't really make sense to wrap in an error, and should instead just be included in a return value.

My questions are, what are the real world use-cases here? How do you guys actually see yourselves using a feature like this in practice? What errors do you have to explicitly handle with a try/catch, and do these instances occur frequently? How would the added type information help you?

@phaux
Copy link

phaux commented Mar 4, 2024

chicken and egg problem

I don't see it that way.

First step should be to implement typechecking of throw types the same way as return types. Then add throw types to standard library definitions which are part of TypeScript.

Then the library authors could just regenerate their definitions like always and have the throw types inferred the same way return types are inferred when you don't specify them. For backward compatibility, functions without a throw type would be treated as throw any or throw unknown, so if a library depends on another library which haven't been updated yet, it just gets it's own throw types inferred as unknown.

@RyanCavanaugh
Copy link
Member Author

"What if TS had typed/checked exceptions" is off-topic here; this is not a place to re-enact #13219

@thw0rted
Copy link

thw0rted commented Mar 4, 2024

"What if TS had typed/checked exceptions" is off-topic here

"Talk about exceptions here" 🤔

ETA: any chance the TS team would consider enabling the GitHub "Discussions" feature for posts like these? Issues are terrible at capturing long-running discussions because once there are too many comments, context gets lost behind the "Load more..." link and search breaks down.

@bensaufley
Copy link

I agree that the topic of this thread is unclear about what's already been litigated, but it has already been extensively litigated (even if I'm bummed about the result). I think this thread was intended to be more of "other options, now that that decision has been made"

@phaux
Copy link

phaux commented Mar 5, 2024

I only found this issue after the previous one was closed.

My usecase was:

I wanted to enforce that a handler function will throw only HttpErrors:

type Handler<T, E extends HttpError<number>> = (req: Request) => T throw E

and I wanted to infer possible responses and their status codes based on what the function actually throws:

type handlerResponse<H extends Function> = 
  H extends (...args: any[]) => infer T throw HttpError<infer N>
    ? TypedResponse<200, T> | TypedResponse<N, string>
    : never

@Kashuab
Copy link

Kashuab commented Mar 11, 2024

I wonder if a simple util function could suffice.

function attempt<E extends Error,  T>(cb: () => T, ...errors: E[]): [T | null, E | null] {
  let error: E | null = null;
  let value: T | null = null;
  
  try {
    value = cb();
  } catch (err) {
    const matches = errors.find(errorClass => err instanceof errorClass);
    
    if (matches) {
      error = err;
    } else {
      throw err;
    }
  }
  
  return [value, error];
}

class StringEmptyError extends Error {}

function getStringLength(arg: string) {
  if (!arg.trim()) throw new StringEmptyError();
  
  return arg.length;
}

// Usage:

const [length, error] = attempt(() => getStringLength(" "), StringEmptyError);
// if error is not a StringEmptyError, it is thrown

if (error) {
  // error is a StringEmptyError
}

This is just an idea. It would need to be improved to handle async functions. It could also probably be changed to compose new functions to avoid repeating callbacks and errors, for example:

class StringEmptyError extends Error {}
class SomeOtherError extends Error {}

function getStringLength(arg: string) {
  if (!arg.trim()) throw new StringEmptyError();
  
  return arg.length;
}

// ... Assuming `throws` is defined
const getStringLength = throws(
  (arg: string) => {
    if (!arg.trim()) throw new StringEmptyError();
  
    return arg.length;
  },
  StringEmptyError,
  SomeOtherError
);

// Same usage, but a bit simpler

const [length, error] = getStringLength(" ");
// if error is not a StringEmptyError or SomeOtherError, it is thrown

if (error) {
  // error is a StringEmptyError or SomeOtherError
}

@thw0rted
Copy link

That's a handy wrapper for, uh, turning TS into Go I guess? (There are worse ideas out there!) But I can't figure out how this helps with static analysis to enforce error checking. In particular, it looks like attempt(...) can only ever return [T,null] | [null,E] but TS isn't able to take advantage of that with flow-control based narrowing.

@Kashuab
Copy link

Kashuab commented Mar 12, 2024

My example wasn't meant to be perfect. It was just an idea on how to accomplish some way of better error handling.
I've since improved the approach and implemented a way to enforce that errors are caught.

Example:

class StringEmptyError extends Error {}
class SomeOtherError extends Error {}

const getStringLength = throws(
  (arg: string) => {
    if (!arg.trim()) throw new StringEmptyError();

    return arg.length;
  },
  StringEmptyError,
  SomeOtherError
);

const length = getStringLength(' ')
  .catch(SomeOtherError, err => console.error(err))
  .catch(StringEmptyError, err => console.error(err));

console.log(length); // would be undefined in this case, it hits StringEmptyError

See CodeSandbox for a working throws implementation. src/throws.ts

If you don't add .catch(Error, callback) for each required error, you cannot access the original function's return value, and the function won't even be called. All errors are typed as expected. There are probably bugs and ways to improve it, I didn't take too much time here. This is definitely not compatible with async functions. Just wanted to prove that something like this is feasible.

Update: I also took the liberty of publishing this in a ts-throws NPM package. If anyone is interested in this feel free to try it out and add suggestions/issues on the repo: https://github.com/Kashuab/ts-throws

After some further development on this there are some obvious problems. But I think they can be addressed.

UPDATE 2: I've added more improvements to ts-throws to handle async functions and fixed quite a few bugs. It's in a pretty solid spot and I imagine it would work for a lot of developers. Check out the README for latest usage examples! Would love to hear some feedback.

@raythurnvoid
Copy link

raythurnvoid commented Mar 13, 2024

@kvenn I've tried my hand at an eslint plugin: https://github.com/Kashuab/eslint-plugin-checked-exceptions/tree/main

It introduces two rules:

  • uncaught-errors

Checks to see if a function you're calling has a @throws JSDoc annotation. If you don't wrap the function call in a try/catch it will output an error.

  • undocumented-errors

Warns you if a function has a throw statement without a corresponding @throws annotation. Matches based on what you're throwing, in case you have custom error classes.

Check out the tests for examples and what it covers. It's been a while since I looked at this, but I remember it being a bit buggy (i.e. nested branches, complicated logic) so there's a ton of room for improvement. I might take another look to improve it.

Side note - the README suggests you can install it from NPM, this is not the case haha.

(This is my work GH account, dunno why I have a separate one but oh well. I previously contributed here as @KashubaK)

This is really neat, btw I would suggest to not enforce try catch, because it's legit to ignore the error and let it propagate without putting eslint comments to disable the rule everywhere. Instead I would propose to force the user to annotate a function that is not catching an error with a @throws as well, this way the user can choose to ignore errors but at least the function openly declares that it may @throws.

@mharj
Copy link

mharj commented Mar 17, 2024

We can always use and wrap something like Rest style Result to handle error types, but long as actual throw error types are not part of TS this is just extra layer hack (same as trying to handle this on JSDoc)
Easy things are propably just utilize Promise generics for Error Promise<string, TypeError>.
Also adding throws keyword return type would also make sense

function hello(arg: unknown): string throws<TypeError> {}

... and have defaults like throws<any> or throws<unknown> based on TS settings.
or maybe more compatible return type setup would be actually string & throws<TypeError> ?

@Kashuab
Copy link

Kashuab commented Mar 24, 2024

I'd like to re-plug a library I put together, since it's more refined than the examples I posted before. It lets you wrap a given function with enforced error catching, using syntax with similar verbosity when compared to a function using throws proposal and try/catch

  • Handle each error case with a separate callback, improved flow control vs. try/catch
  • Consumers don't need to import error classes
  • Everything is typed properly, auto-complete of catch* methods is available and they do get narrowed down so you don't have duplicates
  • No Result, but changes the return type to T | undefined if a checked error is thrown
  • No known bugs as of this comment
import { throws } from 'ts-throws';

class StringEmptyError extends Error {}
class NoAsdfError extends Error {}

const getStringLength = throws(
  (str: string) => {
    if (!str.trim()) throw new StringEmptyError();
    if (str === 'asdf') throw new NoAsdfError();
    
    return str.length;
  },
  { StringEmptyError, NoAsdfError }
);

/*
  `throws` will force you to catch the provided errors.
  It dynamically generates catch* methods based on the object of errors
  you provide. The error names will be automatically capitalized.
*/

let length = getStringLength(' ')
  .catchStringEmptyError(err => console.error('String is empty'))
  .catchNoAsdfError(err => console.error('String cannot be asdf'));

// length is undefined, logged 'String is empty'

length = getStringLength('asdf')
  .catchStringEmptyError(err => console.error('String is empty'))
  .catchNoAsdfError(err => console.error('String cannot be asdf'));

// length is undefined, logged 'String cannot be asdf'

length = getStringLength(' ')
  .catchStringEmptyError(err => console.error('String is empty'))

// Only one error caught, `length` is:
// { catchNoAsdfError: (callback: (err: NoAsdfError) => void) => number | undefined }
// Function logic not invoked until last error is handled with `.catch`

length = getStringLength('hello world')
  .catchStringEmptyError(err => console.error('String is empty'))
  .catchNoAsdfError(err => console.error('String cannot be asdf'));

// length is 11

One improvement might be error pattern matching for things like throw new Error('Some custom message'), this would help with wrapping third-party functions where their exception classes aren't public/exported

I think the only advantage that a native throws keyword would have over something like this would be conditionals (i.e. extends in a throws definition, function overrides, etc.) This solution doesn't seem like a hack to me, since it accomplishes the critical goal of forcing consumers of a given function to catch specific errors. I also prefer this catch-callback approach, it's cleaner than having to narrow error types manually within a catch block in most scenarios.

@thw0rted
Copy link

thw0rted commented Mar 8, 2025

I don't think the exception-to-status pattern is necessarily a bad thing but you have to do it carefully.
I haven't used tRPC but their system sounds pretty similar to how NestJS does it. In Nest, there are a number of "their" framework-specific exceptions you can throw as a shorthand (UnauthorizedException, NotFoundException, etc), but you can also register an exception filter with the framework that will translate "your" exceptions into a meaningful error status.

For example, if your DB library throws its own "record not found" exceptions, you could register a filter that catches those and returns a 404 status, maybe with some detail about the query ("...with 'id'=123"), and you write that logic once. If you have a popular framework and a popular DB library, someone might already have written this filter for you. If you've inherited a legacy project that throws custom exceptions that have a consistent meaning, you can still write one filter yourself and avoid "undesired complexity".

So I think there are good patterns available for exception-based status handling, if you are using a framework. I do also understand wanting to have tooling to statically catch likely / expected exceptions, because I've written enough vanilla Express applications to know that handling Promise rejections there can be really thorny. In the previous issue I argued a couple of times that I don't really care about tracking specific throw-type, but I would really find "throws never", or more specifically "never rejects", useful in a lot of situations.

@phaux
Copy link

phaux commented Mar 8, 2025

@DScheglov Thank you for this comparison table. As you can see, frameworks either:

  • require you to return error instead of throwing it so they can then infer what are the possible errors on the client (Elysia, Hono)
  • Allow throwing errors, but the error type is lost on the client side (TRPC)

I want to have both. for example:

Server:

export async function handlePostCreate(req: Request) {
  if (req.method !== "POST") throw new MethodNotAllowedError()
  if (new URL(req.url).pathname != "/posts") throw new NotFoundError()
  const session = await db.sessions.find(req.headers.get("Authorization"))
  if (session == null) throw new UnauthorizedError()
  if (!session.user.isAdmin) throw new ForbiddenError()
  const data = new FormData(req)
  const title = data.get("title")
  if (!title) throw new BadRequestError("title")
  const body = data.get("body")
  if (!body) throw new BadRequestError("body")
  const id = uuid()
  await db.posts.insert({ id, title, body })
  return { id, title, body }
}

Client:

const result: Result = await typedFetch<typeof handlePostCreate>("POST", "/posts")
// the type of result is something like:
type Result =
  | { status: 405, message: "Method not allowed" }
  | { status: 404, message: "Not found" }
  | { status: 401, message: "Unauthorized" }
  | { status: 403, message: "Forbidden" }
  | { status: 400, message: "Bad request", field: "title" | "body" }
  | { status: 500, message: "Internal server error" } // all other errors turn into this
  | { status: 200, data: { id: string, title: string, body: string } }

@DScheglov
Copy link

DScheglov commented Mar 8, 2025

@DScheglov Thank you for this comparison table. As you can see, frameworks either:

  • require you to return error instead of throwing it so they can then infer what are the possible errors on the client (Elysia, Hono)
  • Allow throwing errors, but the error type is lost on the client side (TRPC)

I want to have both. for example:

Server:

export async function handlePostCreate(req: Request) {
  if (req.method !== "POST") throw new MethodNotAllowedError()
  if (new URL(req.url).pathname != "/posts") throw new NotFoundError()
  const session = await db.sessions.find(req.headers.get("Authorization"))
  if (session == null) throw new UnauthorizedError()
  if (!session.user.isAdmin) throw new ForbiddenError()
  const data = new FormData(req)
  const title = data.get("title")
  if (!title) throw new BadRequestError("title")
  const body = data.get("body")
  if (!body) throw new BadRequestError("body")
  const id = uuid()
  await db.posts.insert({ id, title, body })
  return { id, title, body }
}

Client:

const result: Result = await typedFetch<typeof handlePostCreate>("POST", "/posts")
// the type of result is something like:
type Result =
  | { status: 405, message: "Method not allowed" }
  | { status: 404, message: "Not found" }
  | { status: 401, message: "Unauthorized" }
  | { status: 403, message: "Forbidden" }
  | { status: 400, message: "Bad request", field: "title" | "body" }
  | { status: 500, message: "Internal server error" } // all other errors turn into this
  | { status: 200, data: { id: string, title: string, body: string } }

replace throw with return and you will be able to reach your goal.

@phaux
Copy link

phaux commented Mar 8, 2025

It's not that simple when some of the checks are deep in the call stack. That was exactly my problem when I used Elysia or Hono.

// should infer: throws ForbiddenError | UnauthorizedError | etc
export async function handlePostCreate(req: Request) {
  const admin = await getAdminUser(request)
  // ...
}

async function getAdminUser(req: Request) {
  const user = await getUser(req)
  if (!user.isAdmin) throw new ForbiddenError()
  return user
}

async function getUser(req: Request) {
  const session = getSession(req)
  const user = await db.users.get(session.userId)
  if (user == null) throw new UnauthorizedError()
  return user
}

async function getSession(req: Request) {
  const token = getToken(req.headers)
  const session = await db.sessions.get(token)
  if (session == null) throw new UnauthorizedError()
  return session
}

function getToken(headers: Headers) {
  const auth = headers.get("Authorization")
  if (auth == null) throw new UnauthorizedError()
  if (!auth.startsWith("Bearer ")) throw new BadRequestError()
  return auth.substring(7)
}

Now, if you change them to return new Error you have to also add if with early return every time you call a function.

@thw0rted
Copy link

thw0rted commented Mar 8, 2025

replace throw with return and you will be able to reach your goal.

I said upthread, if you want to turn TypeScript into Go or Rust there are libraries to support that, but that doesn't invalidate the request from the rest of us -- who are not going to do that! -- to support the most useful static analysis we can get for the way JS actually works, which is exceptions, not error returns.

@DScheglov
Copy link

DScheglov commented Mar 8, 2025

@thw0rted

replace throw with return and you will be able to reach your goal.

I said upthread, if you want to turn TypeScript into Go or Rust there are libraries to support that, but that doesn't invalidate the request from the rest of us -- who are not going to do that! -- to support the most useful static analysis we can get for the way JS actually works, which is exceptions, not error returns.

oh, no ...
I don't want to turn TS into Go. But yes, we should use one tools for errors and another one -- for exceptions.

The problem with approach by @phaux :

async function getUser(req: Request) {
  const session = getSession(req)
  const user = await db.users.get(session.userId)
  if (user == null) throw new UnauthorizedError()
  return user
}

is that we mix in the single function both infrastructure items:

  • client interface (http)
  • data persistence interface (database)

and more then, we hardly binding this code to http-framework, that could be changed.
What if some day the http-framework dies? We will need to rewrite entire the project, but actually we need just to rewrite the controllers.

I've refactored the initial handler by @phaux to:

export const handlePostCreate = pipe(
  async ({ title, body }: { title: string, body: string }) => {
    const id = uuid()
    await db.posts.insert({ id, title, body })
    return json(201, { id, title, body })
  },
  validation(
    formData,
    struct({ title: string, body: string }).unpack,
    (status, error) => {
      if (!isCastingError(error)) throw error;
      return json(status, { error: error.message, path: error.path })
    }
  ),
  restrictAccess(({ isAdmin }) => isAdmin, jsonError),
  mountTo("POST", "/posts", jsonError),
  handleError(
    (error: unknown) => Sentry.captureException(error),
    () => json(500, { error: "Internal Server Error "})
  )
);

And then we can do something like that:

type CreatePostResponse = ResponeFrom<typeof handlePostCreate>;

declare const { status, body }: CreatePostResponse;

switch(status) {
  case 201:
    const check201: Expect<Equal<typeof body, Post>> = true;
    break;
  case 400:
    const check400: Expect<Equal<typeof body, { error: string, path: string[] }>> = true;
    break;
  case 401:
    const check401: Expect<Equal<typeof body, { error: string }>> = true;
    break;
  case 403:
    const check403: Expect<Equal<typeof body, { error: string }>> = true;
    break;
  case 404:
    const check404: Expect<Equal<typeof body, { error: string }>> = true;
    break;
  case 405:
    const check405: Expect<Equal<typeof body, { error: string }>> = true;
    break;
  case 500:
    const checkRest: Expect<Equal<typeof body, { error: string }>> = true;
    break;
  default:
    const checkAllHandled: Expect<Equal<typeof body, never>> = true;
}

The full code on the TS Playground

@MrOxMasTer
Copy link

MrOxMasTer commented Mar 16, 2025

I understand your concern about reliability, but please respond:

I spend about half my "writing responses" energy explaining to people why we shipped a feature that seems incomplete or imperfect -- they will tell me we shouldn't do anything if it can't be done perfectly or at least near-perfectly. From what we can tell, any feature here would be well short of any reasonable expectation.

Part of the reason TypeScript is enjoyable to use is that it isn't chock full of mostly-broken functionality. The absence of those features isn't particularly palpable, but it matters when a language tries to do things it can't and fails. And honestly the features I regret the most are the ones we implemented due to popular demand despite knowing they wouldn't work out well in most cases.

you know, I understand that you want to do it perfectly. I want to do the same thing.

I'm trying to write a project right now. You know why I'm trying? Because I've been racking my brains for a month now because I have to use a separate library called Effect for error typing so that I could just have a display of errors and process them. And it's even funnier when you have to cache it. And I can't, because Effect is a non-serializable value. I would have to encode it on the server and decode it ON THIS server just to be able to cache it.

Just so I can have error prompts. What do you think?

@phaux
Copy link

phaux commented Mar 17, 2025

Effect is a great example why typed errors are useful.

@DScheglov
Copy link

Effect is a great example why typed errors are useful.

Errors are not exceptions )
Try to use them separatelly

@ravshansbox
Copy link

@MrOxMasTer effect is just a description of what needs to be done, why one may need to cache it, even why it needs to be serialised?

@MrOxMasTer
Copy link

MrOxMasTer commented Mar 17, 2025

@MrOxMasTer effect is just a description of what needs to be done, why one may need to cache it, even why it needs to be serialised?

It doesn't need to be serialized. I'm originally talking about cache. For example next.js caching mechanisms - they only work with serializable values, and the rest magically do NOT serialize. Like for example classes in superjson.

This is why caching in next.js doesn't work with Effect - since it's not just a description. It needs to be cast to a normal function -> that returns a promise or just a result or Exit

@phaux
Copy link

phaux commented Mar 17, 2025

@DScheglov I just want to have typed Errors like in Effect, but with normal syntax too (throw and catch)

@DScheglov
Copy link

@phaux

@DScheglov I just want to have typed Errors like in Effect, but with normal syntax too (throw and catch).

Having normal syntax for typed errors is not the same as having throws.

Let's look at Rust: Error Handling.

In Rust, it is standard to use Result<T, E> and panic! in different cases. For recoverable errors, we turn them into values and return them from functions, while for unrecoverable errors, we use panic! to throw an exception.

Rust provides a convenient syntax for handling Result values:

fn parse_coords(input: &str) -> Result<(f32, f32), Box<dyn std::error::Error>> {
    let mut parts = input.split_whitespace();

    let x = parts.next().ok_or("Missing X coordinate")?.parse::<f32>()?;
    let y = parts.next().ok_or("Missing Y coordinate")?.parse::<f32>()?;

    if parts.next().is_some() {
        return Err("Too many values".into());
    }

    Ok((x, y))
}

fn main() {
    match parse_coords("12.5 45.8") {
        Ok((x, y)) => println!("Valid coordinates: ({}, {})", x, y),
        Err(e) => println!("Validation failed: {}", e),
    }
}
  • operator ?
  • pattern matching

At the same time, we can use panic!:

use std::panic;

fn parse_coords(input: &str) -> (f32, f32) {
    let mut parts = input.split_whitespace();

    let x = parts.next().expect("Missing X coordinate").parse::<f32>().unwrap();
    let y = parts.next().expect("Missing Y coordinate").parse::<f32>().unwrap();

    if parts.next().is_some() {
        panic!("Too many values");
    }

    (x, y)
}

fn main() {
    let result = panic::catch_unwind(|| parse_coords("12.5 45.8"));

    match result {
        Ok((x, y)) => println!("Valid coordinates: ({}, {})", x, y),
        Err(_) => println!("Validation failed: Panic occurred!"),
    }
}

Instead of requiring throws, it is better to support Result.
I've described this approach here: #56365 (comment).

Currently, I can implement this Rust example in TypeScript using resultage (similar to EffectTS but much simpler):

import { Result, ok, err, Do } from 'resultage';

function parseNumber(value: string): Result<number, TypeError> {
    const parsed = parseFloat(value);
    return isNaN(parsed) ? err(TypeError(`'${value}' is not a valid number`)) : ok(parsed);
}

function parseCoords(input: string): Result<[number, number], Error> {
    return Do(function* () {
        const parts = input.trim().split(/\s+/);

        if (parts.length !== 2) {
            return err(Error('Input must contain exactly two space-separated values'));
        }

        const x = yield* parseNumber(parts[0]);
        const y = yield* parseNumber(parts[1]);

        return [x, y] as [number, number];
    });
}

const result = parseCoords('12.5 45.8');

result.match(
  ([x, y]) => console.log(`Valid coordinates: (${x}, ${y})`),
  (e) => console.error(`Validation failed: ${e}`),
);

However, I would like to be able to do the following:

// -- snip --

function parseCoords(input: string): Result<[number, number], Error> {
    const parts = input.trim().split(/\s+/);

    if (parts.length !== 2) {
        return err(Error('Input must contain exactly two space-separated values'));
    }

    const x = check parseNumber(parts[0]); // checking result and evaluating to value of Ok
                                           // OR exiting the function with Err
    const y = check parseNumber(parts[1]);

    return ok([x, y] as [number, number]);
}

// -- snip --

So, we need the check (or try?) operator to unwrap objects having [symbol.Result]() method

It looks like this proposal should be addressed to the TC39 Committee, not to TS.

But in the case of HttpError<Status, Payload>, there is no need for exceptions or Result because the successful case (i.e., statuses 2xx) should be handled using the same response container.

@phaux
Copy link

phaux commented Mar 18, 2025

I love Rust and I'm not even against adding a Result type, but that's just a workaround for lack of type checking of error types. We already have an async Result type in the language (Promise) and a result-unwrapping operator (await) and it sucks because there's no type checking on errors. First we need to add E to Promise<T, E> and everywhere else. Adding Result and unwrap operator is a completely different issue and off-topic.

@DScheglov
Copy link

@phaux

Adding Result and unwrap operator is a completely different issue and off-topic.

Really?

@DScheglov I just want to have typed Errors like in Effect, but with normal syntax too (throw and catch)

Result -- is exactly about typed errors like in Effect

@phaux
Copy link

phaux commented Mar 18, 2025

I know I can just use a library, but I don't want to. Especially a huge library like Effect. Smaller ones don't seem very useful neither, because they don't wrap standard APIs like fetch with their own result-returning versions. I know I can do it myself but then I'm just rewriting Effect from scratch.

The error types should be in the standard lib definitions bundled with TS. Not in the wrappers that we have to write ourselves.

@DScheglov
Copy link

@phaux

I know I can do it myself but then I'm just rewriting Effect from scratch.

Oh, I don't think introducing own implementation for result could be even compared with Effect (it's overhead).

because they don't wrap standard APIs like fetch with their own result-returning versions.

I guess each team will want their own logic for wrapping fetch.

I do something like that:

const CreatePostResponse = oneOf(
  JsonResponse(201, Post),
  JsonResponse(400, struct({ error: string, path: array(string) })),
  JsonResponse(401, ErrorBody),
  JsonResponse(403, ErrorBody),
  JsonResponse(404, ErrorBody),
  JsonResponse(405, ErrorBody),
  JsonResponse(500, ErrorBody),
);

const { status, body } = await fetch("https://example.com/api/v1/posts", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "The Post", body: "The Post Body" }),
})
  .then(jsonResponse)
  .then(CreatePostResponse.unpack);

See TS Playground

I'm using my own implementation: castage that returns Result from resultage.
You can use anything else -- there are a lot of different solutions.

@phaux
Copy link

phaux commented Mar 18, 2025

I don't see how these code examples are related to the discussion or what is the point of them other than to advertise your library.

@DScheglov
Copy link

DScheglov commented Mar 18, 2025

I don't see how these code examples are related to the discussion or what is the point of them other than to advertise your library.

Pani Nikita (@phaux ), with all respect. You describe your need:

const result: Result = await typedFetch<typeof handlePostCreate>("POST", "/posts")
// the type of result is something like:
type Result =
  | { status: 405, message: "Method not allowed" }
  | { status: 404, message: "Not found" }
  | { status: 401, message: "Unauthorized" }
  | { status: 403, message: "Forbidden" }
  | { status: 400, message: "Bad request", field: "title" | "body" }
  | { status: 500, message: "Internal server error" } // all other errors turn into this
  | { status: 200, data: { id: string, title: string, body: string } }

I've shown how to reach that without throws.
Then you said that you need the same on the fetch side, I've shown that -- and it is not a rocket science.

I use my libraries and just mention what I use, I don't expect you or somebody else will start using it.
You can use the io-ts for the same reason.

@phaux
Copy link

phaux commented Mar 18, 2025

Panie @DScheglov, I thought we already changed topics from end-to-end type safe server/client frameworks (like TRPC) to functional programming libs (like Effect).

Now, if you create a TRPC alternative built on top of Effect primitives, that would be impressive and I personally would consider using it (although I would still prefer typed throws more than Results or whatever Effect has).

@DScheglov
Copy link

@phaux

The only thing I'd like to say: if someone needs typed errors, they should just return them instead of throw.

@phaux
Copy link

phaux commented Mar 18, 2025

Tell that to designers of the DOM API and other standard JS functions.

@DScheglov
Copy link

Tell that to designers of the DOM API and other standard JS functions.

It is interesting...

Could you please explain why do you need a typed error here?

@ianldgs
Copy link

ianldgs commented Mar 19, 2025

Could you please explain why do you need a typed error here?

One clear example would be playing audio on a browser:
https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play

  • If it throws NotAllowedError, I want to display a message to the user: "Please enable the permission to play audio"
  • If it throws NotSupportedError, I want to display a message to the user: "Your browser does not support playing audio, please download a more modern browser".

Having the HTMLMediaElement.play method annotated with those 2 exceptions would save me a trip to the documentation to find out which exceptions it throws.

Could it be done with ifs? Yes, it's just tedious, given that the designers of this DOM API went through the trouble of implementing the language's control flow for error handling that allows you to treat it.

For me the main points against using a Result type (either your own or from a library) are:

  • Doesn't integrate well with tools in the status quo of the open source community that expect an error to be thrown or a promise to be rejected to indicate an error state. E.g. redux toolkit's rtk-query, react's ErrorBoundary (componentDidCatch)
  • Still has to worry about error/exception: what if something is thrown at some point in your application?
  • Result types are usually not compatible across libraries: Effect's way of doing it may be different from resultage. What if a third library chose one implementation and your application chose another?
  • Doesn't really force you to handle errors.
    In one of our apps we had a Result class with isOk or isErr. People would just do: if (result.isOk) doMyThing(result.data) and swallow the error.
    With error/exceptions, if not handled, it will be propagated until an application-wide handler, which may report to some tool like Sentry.

@DScheglov
Copy link

Hey, @ianldgs,

Thank you for your response.

The example with the Video Player is an excellent case for using Result, as it seems that we should handle the corresponding errors each time, directly at the place where we invoke the .play() method. Otherwise, the UX will suffer.

However, if we're only discussing documentation:

Having the HTMLMediaElement.play method annotated with those two exceptions would save me a trip to the documentation to find out which exceptions it throws.

This could already be addressed with JSDoc:

Image

See it in the TS Playground.

I assume the corresponding PR will be approved and merged.

Regarding your comments about the Result type: it is not a replacement for exceptions, nor could it be. What should the runtime do when attempting to "unwrap" an Err value? Even in Rust, Result is not a replacement for exceptions (panic!).

Should Result enforce handling errors? That’s a really good question. I believe there is no silver bullet that forces anyone to handle errors. However, if Result is used correctly, it will be obvious that the error value shouldn't be ignored.

If Result is used as a substitution for exceptions, then yes—it can lead to undesired results. Centralized error boundaries exist for a reason, and it is absolutely normal to delegate error handling to them. However, in my opinion, Result should not reach error boundaries at all.

What does "correct usage" mean in the context of Result? In my opinion, it is perfect for domain errors that originate deep within an application but must be translated for the end-user to aid in recovery. This means we cannot declare these errors with overly broad types (such as Error or string); instead, we must narrow their type as much as possible.

I've been using Result (Either) in production for four years, and I'm very satisfied with it. It allows me to gracefully separate cases—such as when a database exception should be translated into a domain-level error versus when it should be thrown, caught by an error boundary, and reported to Sentry.

Here’s an example from a real production implementation of a SCIM Server, where we attempt to insert a user into a table. If the user already exists, we respond to the Identity Provider with the appropriate error.

const DirectoryUserRepo = (runQuery: DbQueryRunner): IDirectoryUserRepo => ({
  add: (user) =>
    runQuery<DirectoryUser>(
      sql`INSERT INTO directory_users (
         // -- snip --
      ) VALUES (
         // -- snip --
      ) RETURNING *`,
    ).then(
      (rows) => Right(getFirst(rows)),
      (error) => {
        if (!(error instanceof DatabaseError)) throw error;
        if (error.code !== UNIQUE_VIOLATION) throw error;
        const field = UniqueFieldByConstraint[error.constraint ?? ''];
        if (field === undefined) throw error;

        return Left({
          code: 'EDUPLICATED_ENTITY',
          field,
          value: user[field],
        });
      },
    ),
  // -- snip --
});

Later, we handle it like this:

return directoryUsers.add(user).then(
  match(
    ({ field, value }) => {
      scimLogger.logEntityProcessing(
        addProcessingData("User").notUnique(user),
      );
      return ScimNotUniqueError("User", field, value);
    },
    (createdUser) => {
      const res = toScimEntity(directory, createdUser);
      scimLogger.logEntityProcessing(
        addProcessingData("User").ok(createdUser, res),
      );
      return res;
    },
  ),
);

The compiler ensures we handle all cases correctly—if I introduce a new error code with a new payload, it will notify me if I fail to handle the Left scenario properly.

On the front end, we also use Result (Either). We use GraphQL, and for all mutations, we employ a union result type:

union SignUpResult = SignUpSuccess | SignUpFailure

type SignUpSuccess { token: String! }
type SignUpFailure { errorCode: SignUpFailureCode! message: String }

enum SignUpFailureCode {
  EALREADY_EXISTS_USER
  EINVALID_PASSWORD_LENGTH
  EINVALID_PASSWORD_CONTENT
  EINVALID_PASSWORD_STRENGTH
}

On the client side, we assign SignUpSuccess to Right (Result.ok) and SignUpFailure to Left (Result.err). The rest are thrown as exceptions and handled by React.ErrorBoundary. The NotAuthorized error is managed at the GraphQL client middleware level, preventing it from reaching the ErrorBoundary.

In the component, we can cleanly differentiate between Right<SignUpSuccess> and Left<SignUpFailure>:

  • On Left: render <Alert type="error">{t(errors.${result.errorCode})}</Alert>
  • On Right: render an Alert with success type and transition to the next page.

No try and catch needed. In our projects, we do not use Either<Error, T> or Either<string, T>. Instead, we use highly specific error types.

@kvenn
Copy link

kvenn commented Mar 19, 2025

Image

I think talking about Results is a little outside the scope of this thread, @DScheglov. And it appears I'm not the only one that thinks that. As you point out, they're just different things from exceptions and this thread is specifically only about exceptions in Typescript. I'm a big fan of sealed class and, when that's not supported by the language, an Either type. In my own code, I use that pattern. But Results/sealed-classes/eithers aren't always an option (or the right option). Sometimes you're working with another library that throws an exception from typescript. Sometimes it's even documented correctly. But Typescript falls short in a couple ways.

In the case of exceptions already being ubiquitous in the ecosystem, it's not really viable to just tell everyone to switch to a different pattern. So ideally the language itself would provide tools to better support this ubiquitous pattern of exceptions (which is what this thread is about).

This conversation thread (and it's predecessor) covers a lot of ground, but if I can summarize what I'm seeing, it's mostly about

  • Not having any compile-time enforcement or checking for specific error types (without blocky if instance-of syntax). You can see how Java handles it built-in. @ianldgs and @phaux have good points on this one
  • The compiler/linter not having the ability to warn you when you have an uncaught exception. This gets into the very controversial debate of checked vs unchecked exceptions. But even without the language taking a stance, there could be ts rules for warnings.

And on both of these points, TSDoc is the only way to declare it, which has limitations and isn't exactly bullet-proof, since a single typo can break it. Again, you can look to languages like Java or kotlin or swift on how they declare if/what errors are thrown.

To summarize, I agree with you results are cool. And it would be even cooler if they added some of the features supported by declaring your own result types into exceptions themselves. Since error handling in TS is honestly pretty wack. And if you don't plan to talk about exceptions, it might make sense to move your responses to a different thread.

@DScheglov
Copy link

@kvenn

And if you don't plan to talk about exceptions, it might make sense to move your responses to a different thread.

We are talking here about Typed Exceptions.

The position of TS team is that unknown is an exact type for exceptions.
But really there are cases when we need a typed errors and we need to utilize type system to check that everything is handled correctly.

In TS -- exceptions are not a good way to solve this, considering current ecosystem state. The Result is not a unique way to solve that.

The solution is simple -- don't throw in non exception cases, and that it. And it is also talking about the exception.

Now it looks like such frameworks like tRPC use exceptions in goto style -- and then we have arguments here that we need to type errors.

Regarding this:

... TSDoc is the only way to declare it, which has limitations and isn't exactly bullet-proof, since a single typo can break it. ...

  • yes, it is not a bullet-proof, throws in Java is also not a bullet-proof, because in any moment it could be something like: throws Throwable -- the same as throws unknown.

  • no, the typo doesn't break anything. Like any typo in the documentation as well. Yes, it will complicate development, but it will not break the program.

@snarbles2
Copy link

You really can't explore a problem space without examining the tangential issues. Discussing other solutions and where and when exceptions don't need to be "solved" adds at least as much value as rehashing the same points again for the nth time.

@phaux
Copy link

phaux commented Mar 19, 2025

See it in the TS Playground.

The correct way to type play would be:

interface HTMLVideoElement {
  play(): Promise<void, DOMException<"NotAllowedError" | "NotSupportedError">>
}

So, no. We can't use JSDoc @throws in this case, because:

  1. It doesn't throw. It returns a promise which rejects.
  2. You can't add error type to Promise. TS will error that it only takes 1 generic param.
  3. Even if it wasn't a Promise but a regular throw it would be ignored by TS anyways.

Now, if TS was actually checking the throw types it would also tell you there's no such class as NotAllowedError.

That was an excellent example of the quality of the documentation we are gonna get without TS support for checking throw types. Thank you <3

@DScheglov
Copy link

@phaux

interface HTMLVideoElement {
  play(): Promise<void, DOMException<"NotAllowedError" | "NotSupportedError">>
}

yes, you are right, I didn't check the correct type for this cases. It must be just DOMException:

interface HTMLVideoElement {
  /**
   * Loads and starts playback of a media resource.
   * 
   * @throws `DOMException & { name: "NotAllowedError" }` if the user agent (browser) or operating system doesn't allow playback of media in the current context or situation
   * @throws `DOMException & { name: "NotSupportedError" }` if the media source (which may be specified as a MediaStream, MediaSource, Blob, or File, for example) doesn't represent a supported media format
   * 
   * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play)
   */
  play(): Promise<void>
}

JSDoc doesn't have the @rejects, but Monaco Editor understands it as well. But it doesn't change the approach, as I said before: any "typo" in documentation doesn't break the program.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Discussion Issues which may not have code impact
Projects
None yet
Development

No branches or pull requests