Skip to content
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

Talk about Exceptions Here #56365

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

Talk about Exceptions Here #56365

RyanCavanaugh opened this issue Nov 10, 2023 · 63 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.

@KashubaK
Copy link

How about instead of all this, in your code you just return an Error, instead of throwing altogether.

@KashubaK I don't like this solution because it forces the developer to mix error handling and logic flow.

Do you mean to tag me in this? I'm not proposing people handle returned errors as you compared against. However ts-throws can capture returned errors so you can handle them in a more catch-y style.

@felds
Copy link

felds commented Apr 30, 2024

@KashubaK Yes, I did.

I quoted a part of your answer, and this is the code you suggested:

const result = wow('haha');
if (result instanceof Error) {
  // Handle the error
  return;
}

I my second exemple I tried to show how it would look inside a function body and how, to me, it doesn't seem very ergonomic. (but maybe I got something wrong, as it's known to happen)

@KashubaK
Copy link

KashubaK commented Apr 30, 2024

@KashubaK Yes, I did.

I quoted a part of your answer, and this is the code you suggested:

const result = wow('haha');
if (result instanceof Error) {
  // Handle the error
  return;
}

I my second exemple I tried to show how it would look inside a function body and how, to me, it doesn't seem very ergonomic. (but maybe I got something wrong, as it's known to happen)

Ohhhhh gotcha. In retrospect, I'm not a huge fan of that either. I was trying to look for "How can we achieve this in current TS" since the conversation of "How can TS change to support this new idea" isn't productive anymore.

I wrote a library called ts-throws that, perhaps verbosely, can facilitate typed error handling (enforced or otherwise.) I'm very curious as to the community's thoughts on something like this. I would love to add improvements and fix things to address concerns.

@callistino
Copy link

callistino commented Jun 7, 2024

I read all comments with my best effort before trying once more to beat down on a simingly dead horse but I may've missed if a similar post was already created so I appologize if that's the case. I appreciate the thorough analysis and feedback regarding the proposal to introduce throws clauses in TypeScript. The points about the JavaScript runtime, ecosystem, and the current state of exception handling in JavaScript are well-taken. However, I believe there is still a compelling case for supporting throws clauses in a way that aligns with the dynamic nature of JavaScript and the static type-checking goals of TypeScript. Here are my counterpoints and suggestions:

Problem

If TypeScript aims to be a statically-typed superset of JavaScript, it should support and enhance all features present in existing JavaScript. Exceptions and error handling, do not have direct static typing counterparts in TypeScript.

Key Points:

Exception Handling: TypeScript's current handling of exceptions using instanceof and type guards is effective but verbose and less intuitive compared to a dedicated throws clause. While the proposed TC39 pattern matching in catch clauses will improve ergonomics, it does not address the core need for static analysis and documentation of possible exceptions thrown by functions.

Ecosystem survey: It is true that many JavaScript libraries do not document exceptions explicitly. However, this should not deter TypeScript from offering a mechanism to improve this situation. By providing throws clauses, TypeScript can lead by example, encouraging better documentation practices in the ecosystem. This is similar to how TypeScript has encouraged the adoption of static types in a traditionally dynamically-typed language community.

Language Capabilities and Cultural Absence: The absence of strongly-typed exceptions in JavaScript is largely due to historical and practical reasons rather than inherent language limitations. JavaScript’s evolution and its use in varied environments (e.g., browser, server) have contributed to this. However, TypeScript’s goal is to bring type safety to JavaScript, and incorporating throws clauses can be seen as a natural extension of this goal, improving error handling and documentation.

Argument for Including throws Clauses

To address the concerns about the impracticality of immediate, widespread adoption and the issues with existing undocumented exceptions, I propose an incremental and flexible approach:

Optional throws Clauses with Default Behavior: Functions can optionally specify throws clauses. If not specified, it defaults to throws unknown. This ensures that existing codebases remain unaffected while new code can opt-in for better error handling and documentation.

function fetchData() throws NetworkError {
  // Function logic
  if (networkFails) {
    throw new NetworkError("Failed to fetch data");
  }
}

Compiler Warnings and Documentation Encouragement: The TypeScript compiler can provide warnings for unhandled exceptions only if a function being called in its body explicitly declares a throws clause. This reduces noise for developers and allows gradual adoption without overwhelming them with warnings from unannotated legacy code.

function processData() {
  fetchData(); // Warning: unhandled NetworkError
}

Enhanced Error Handling and Clarity: By encouraging developers to specify throws clauses, we improve the clarity and maintainability of code. This is especially useful in large codebases and for third-party library integrations, where knowing the types of potential exceptions is crucial.

Aligning with TypeScript’s Goals: Introducing throws clauses aligns with TypeScript’s mission to enhance JavaScript with static types. It provides developers the tools to write safer, more predictable code without changing JavaScript’s runtime behavior.

Reconsideration Points

Adoption and Documentation: As the TypeScript community adopts throws clauses, we can expect better documentation practices to emerge, similar to how TypeScript has improved type documentation in libraries.

@thw0rted
Copy link

@callistino I like a lot of your points but honestly your ideas were pretty well covered in #13219. One key point is that the TS compiler does not generate "warnings", only errors -- any type issues are fatal. You wouldn't think this would be the end of the world, but it means that in a codebase with partial throws-clause coverage, the annotations are either useless (checking flag disabled) or deafening (checking flag enabled).

Personally, I think that this is the first case I've seen of a language keyword that I would like to see added not for the benefit of the compiler, but for 3rd party tooling. The eslint folks have already said that marking promises as "never rejects" is too hard to implement without language support. As you point out, being able to mark unhandled, advertised / expected exceptions as a non-fatal linter warning would be great. And even Intellisense could maybe benefit from "hinting" about exception/rejection types. Still, I understand the team's hesitance to add a keyword that wouldn't actually deliver value to the type-checker.

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Jun 13, 2024

+1 to @thw0rted's comment; the proposed behavior doesn't seem even at all different from what was proposed in #13219. It's just restating the original proposal.

@ethanresnick
Copy link
Contributor

ethanresnick commented Jun 22, 2024

@callistino I'd encourage you to look at #57943, which tries to get the documentation and ergonomics benefits you're describing, without going for checked exceptions (or manually-maintained throws annotations). It maybe runs into the issue @thw0rted mentioned about "the team's hesitance to add a keyword that wouldn't actually deliver value to the type-checker". There are also some open questions about how/if it could be implemented.

But it is trying to solve a similar problem, and in a way that I think makes sense to have in Typescript, rather than re-implementing in a linter, because it:

  • leverages TS machinery that would be a lot of work to re-implement in a linter, around narrowing, error type inference, and — especially — error types in type variables
  • leverages TS' broad adoption to really jumpstart the production of this machine-readable error documentation across the ecosystem.

@sebastianvitterso
Copy link

Most of what I'm reading from others about "type safe try/catch", it seems people want (Java's) function throws statement. As a simpler solution, which probably has been proposed before (I have yet to see it), I think the catch statement should allow types.

So as an example:

// typescript
try { 
  myFunction()
} catch (e: Error) {
  console.error(e)
}

could generate into js:

// javascript
try { 
  myFunction()
} catch (e) {
  if (e instanceof Error) {
    console.error(e)
  } else {
    throw e
  }
}

This would theoretically also allow us to catch multiple types with multiple catch statements, just like Java does, with something like

// typescript
try { 
  myFunction()
} catch (e: Error) {
  console.error(e)
} catch (e: OtherError) {
  console.log("Other error occured")
}

which would become

// javascript
try { 
  myFunction()
} catch (e) {
  if (e instanceof Error) {
    console.error(e)
  } else if (e instanceof OtherError) {
    console.log("Other error occured")
  } else {
    throw e
  }
}

This would require changes to what is accepted as "valid" ts, compared to js, given that multiple catch-statements isn't valid js, but we already write invalid JS as TS, so it's not that far of a stretch.

This would allow us to more easily catch specific errors using the existing try/catch system, without having to touch function syntax or anything of the sort, nor is the developer required to use monads for every function call.

@thw0rted
Copy link

I think this has been suggested in the past, and shot down as a violation of "non goal" 5:

Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata

They really don't like emitting substantially-different JS, it should be possible to just strip away types and more or less generate a valid JS program.

@sebastianvitterso
Copy link

Ah, alright, that makes sense.

And I guess it would be challenging to only be able to catch class-based types here. Something like this might be a challenge:

type A = Error | string
type B = `${'Funny' | 'Boring'}Error`

try {}
catch (e: A) {}
catch (e: B) {}

What would be generated for this, then?

try {}
catch(e) {
  if (e instanceof Error || typeof e === "string") {}
  else if (typeof e === "string" && /^(Funny|Boring)Error$/.test(e)) { /** this test can get super-complex */ }
  else { throw e }
}

Fair fair.

@DScheglov
Copy link

DScheglov commented Aug 18, 2024

I'd like to add my two cents here as well.

When working on the backend, I encounter two different types of errors:

  1. Expected Errors
  2. Unexpected Errors

Expected Errors

Example: When handling the user registration form, we should return something like "ERR_USER_IS_ALREADY_REGISTERED" to the API caller (instead of something vague like "Unique index violation").

  • All expected errors must be reflected in the function signature.
  • The calling function must reflect in its own signature any unhandled expected errors returned by another function it calls.
  • The reflection of unhandled expected errors can be explicit or implicit.

Usually, such errors describe domain-specific cases.

Some protocols working over HTTP typically use the 200 HTTP status for expected errors, treating them as expected but not successful results (as in the case of GraphQL).

Unexpected Errors

Example: When handling the same user registration form, we should return a 500 HTTP status with an error message like "Internal Server Error" if our API server loses the connection to its database.

  • Any function decalred an expected error CAN also meet an unexpected one
  • The type of such errors should be presumed as unknown—they are genuinely unexpected.
  • They must not affect the function signature; otherwise, different implementations of the same interface could break the Liskov Substitution Principle (LSP) and cause a cascading update of all related code (as Robert Martin discusses in Clean Code).
  • They must be sanitized before external presentation.
  • They should be reported to an error tracking system (e.g., Sentry).

Usually, such errors describe infrastructure failures or (explicit or implicit) assertion mismatches. These errors are often returned with an HTTP status of 500 to API clients.

Approach for Expected Errors

Returning to the user registration form...

type UserRegistrationErrorCode =
   | 'ERR_USER_IS_ALREADY_REGISTERED'
   | // --- snip --
;

class UserRegistrationError extends Error {
  constructor(public code: UserRegistrationErrorCode) {
     super(`${code}`);
  }
}

interface IUserService {
  register(userData: UserRegistrationData): Promise<User | UserRegistrationError>;
}

So, we simply return the error explicitly from the register method, rather than throwing it—just return it.

On the calling side, we should handle it like this:

async function signUp(
  userData: UserSignUpData,
): Promise<{ user: User, session: Session } | UserRegistrationError> {
  const registrationData = prepareUserRegistrationData(userData);
  const user = await userService.register(registrationData);

  if (user instanceof Error) {
    // user type is correctly narrowed to UserRegistrationError
    return user;
  }

  const session = await sessionService.startUserSession(user);

  return { user, session };
}

In general, this approach looks simple and robust, but if (user instanceof Error)
can be annoying, especially in deeply nested code.

This method lacks the implicit error propagation that exceptions provide. To
achieve automatic propagation, we might turn to FP’s Either monad and
generators. However, this approach can negatively impact performance and,
even worse, confuse less experienced developers. On the other hand, it does
offer some benefits, such as the ability to handle or map both errors and
successful results in a single expression.

Approach for Unexpected Errors

Simply throw them. Catch the corresponding exception in the error boundary
(depending on the external application interface), sanitize it, and report it.

Sometimes, we need to transform an unexpected error into an expected one.
For example, in the case of the user registration form, the database driver might
return a UniqueIndexViolationError. We must intercept the exception, ensure
that this is the exact error we caught, and handle it by returning a UserRegistrationError
with the appropriate code.

Therefore, the error boundary might not be the only place where we catch
unexpected errors. In other areas, we should convert the error into an expected
one and return it, or we should re-throw the same error (without any modification)
to avoid breaking the stack trace and losing other important information.

It Seems Like We Have Everything We Need

Unfortunately, returning errors introduces a lot of noisy if statements, and it
requires explicit result assignment if the main (non-error) function result is void.

To address this, we need an operator that allows us to return the received error,
enabling us to write code in the following way:

async function signUp(
  userData: UserSignUpData,
): Promise<{ user: User, session: Session } | UserRegistrationError> {
  const registrationData = prepareUserRegistrationData(userData);
  const user = try? await userService.register(registrationData);
  const session = await sessionService.startUserSession(user);

  return { user, session };
}

Additionally, we need an operator that throws any Error-result if we know that
no errors are expected.

async function signUp(
  userData: UserSignUpData,
): Promise<{ user: User, session: Session }> {
  const registrationData = prepareUserRegistrationData(userData);
  const user = try! await userService.register(registrationData);
  const session = await sessionService.startUserSession(user);

  return { user, session };
}

I've chosen try? and try!, but it could be something else.

Additionally, instead of checking if the value is an instance of the
Error class, it's better to introduce a Symbol, such as Symbol.result.

type ResultValue<T, E> =
  | { isError: false, value: T }
  | { isError: true, value: E }

interface Result<T, E> {
  [Symbol.result](): ResultValue<T, E>;
}

So, the protocol for try? looks like:

  1. Pre-condition: The value (the operand of the operator) must implement the Result interface (for non-TS projects, assume this condition is met).
  2. Retrieve resultValue using value[Symbol.result]().
  3. If resultValue.isError, exit the function with a value: Result<never, E>; otherwise, return resultValue.value: T.

For try!, steps 1 and 2 are the same, and:

  1. If resultValue.isError, throw resultValue.value; otherwise, return resultValue.value.

Summary

I understand the TS Team's position regarding language extensions: prioritizing ECMAScript first. This approach makes sense, but the JS community is currently considering a proposal for a "Safe Assignment Operator". However, they are not particularly interested in strict error typing, so we cannot expect TC39 to address typed errors anytime soon.

So, We need a Result Type in TypeScript to tackle the typed errors.

@DScheglov
Copy link

About ts-throws approach to "expected errors".

I've also tried the similar one Function.prototype.throw

The code looks like the following;

const sqrt = (a: number): number =>
  a >= 0 ? Math.sqrt(a) : sqrt.throw(new SqrtError());

sqrt.throws = [SqrtError] as const;

const div = (a: number, b: number): number => 
  b !== 0 ? a / b :
  a !== 0 ? div.throw(new DivisionError("ERR_DIV_BY_ZERO")) :
            div.throw(new DivisionError("ERR_INDETERMINATE_FORM"));

div.throws = [DivisionError] as const;

const quadraticEquation = (a: number, b: number, c: number): [number, number] => {
  const rootOfDiscriminant = sqrt(b * b - 4 * a * c);

  return [
    div(-b - rootOfDiscriminant, 2 * a),
    div(-b + rootOfDiscriminant, 2 * a),
  ];
}

quadraticEquation.throws = [
  ...div.throws,
  ...sqrt.throws,
] as const;

The approach allows to reflect the "expected" errors in function type that could be using in interfaces as well,
but the main problem is a need to manually reflect "inhereted" errors from the called function.

@dylanpizzo
Copy link

I haven't read literally every comment here (there are just too many), but I'm a bit confused about why there's so much focus on enforcing that certain exceptions get caught. In the original proposal for the throws type annotation, TypeScript would check the assignability of functions based on their throws types. This means that if I want to write a function and guarantee it doesn't throw, I can type it with throws never. Similarly, if I'm accepting a callback and want to ensure it cannot throw, I can type the callback parameter with throws never. This approach allows us to opt individual functions into throwing only certain errors.

Isn't this level of control sufficient for most cases? Why should TypeScript enforce that every time you call a function, you have to wrap it in a try/catch block? Such enforcement seems unnecessary when we can already manage exception types through throws annotations and function assignability.

@MrOxMasTer
Copy link

MrOxMasTer commented Feb 28, 2025

Comment

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

By some statement here, I want to say something

I understand your point of view, but answer me a few questions:

  1. Has there ever been a tool in the history of js that allowed good documentation of bugs? Not every developer makes a website for their library, so there is not much to document
  2. When you created typescript, did you think in this category: Who needs it? From the beginning, 'js' didn't have type annotations, so why would we add it? . But you made ts anyway, and now almost everyone uses it. Surprisingly, almost all libraries have typing, although it takes time to type everything, but they did it all and nobody resented it because they understood why.

So how are the errors different? The throw processing is a horror and everyone has already spoken about it. Even there are such tools as Effect-ts which are badly compatible with other libraries, but they try to fix ts and you know - this library has 2 millions downloads, so maybe it's not so bad? And I'll tell you, it's a lot more complicated than just writing throw .... And not all libraries don't describe their errors - for example next-auth describes: https://authjs.dev/reference/nextjs#autherror.
https://authjs.dev/reference/nextjs#credentialssignin
etc.

Most libraries, in principle, do not describe all the functionality in the documentation that is available, but only what they consider necessary, such as Effect-ts Half of the functions are not in the regular documentation, but they are described in the prompts themselves and what are they doing. So did the next-auth. In their callback, half of the parameters are described in the documentation, and some are not and are displayed only in my vs code

The only thing I still question is the implementation - what it should be. Static - so that every developer can anatomise these errors, but the only problem is that - what about the methods that are already anatomised? In this case, either each developer will have to write something like TimeError | RangeError | ... which is very complicated and any type developer can make a mistake. As you say, there are up to 400 errors at some points, but nobody describes them. That's why, most likely, we need a dynamic error analyser, which will show what errors can cause a method, inside which there are methods with unhandled errors.

And by the way, not only errors - for example, React uses throw Promise for Suspense bounds, so not only errors can be thrown.

And I don't see how this proposal - TC39 proposals to implement some sort of pattern-matching criteria to catch exceptions (arguably this would just work ‘by itself’ the same way instanceof works today) would affect your decision to add the throw anotation after all.

This does not change the fact that unknown will remain in catch until you add the throw annotation. And about the Result<T, E> pattern in Rust - there is a proposal like this - proposal. Also - I don't understand why you are referring to Result<T, E> in Rust in the first place. Errors are typed there, while in ts without anotation they are not.

Any template or proposal that you show does not interfere with the creation of annotations, but rather complements them if these proposals are accepted. This does not negate the fact that unknown remains in catch and until the annotations are added, it will remain that way.

It seems to me that if we think in such categories we will never move forward and it seems to me that you will add it anyway. Postponing the inevitable.

There's also this 2 stage thing:
https://github.com/tc39/proposal-throw-expressions
The kind of thing that's at stage 3:
https://github.com/tc39/proposal-is-error

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Mar 1, 2025

@MrOxMasTer even setting aside the points you raised, there are still many others in that comment left unaddressed which are effectively fatal to the feature ever being useful.

If 20% of JS functions have type information, TypeScript can be 20% useful. That was the case from 0.8 at our very first release.

With typed exceptions, if even one function anywhere in a block doesn't have accurate types, then the type in the catch block is wrong and feature isn't really working at all (and is in fact making things worse by claiming to know things which are not actually true).

Then even if everything got exception type information, how do you propose to solve the propagation problem? Function indirection is extremely common in JS, moreso than in many other languages. Without a solution here, we'd lack a mechanism to have this feature work anywhere where a closure is involved, and that happens all the time.

It's fine to look at a list of 20 issues and decide that 3 of them are bogus, but we can't just skip over the other 17.

@MrOxMasTer
Copy link

MrOxMasTer commented Mar 1, 2025

With typed exceptions, if even one function anywhere in a block doesn't have accurate types, then the type in the catch block is wrong and feature isn't really working at all (and is in fact making things worse by claiming to know things which are not actually true).

I understand your concern about reliability, but please respond:

  1. When were types not the responsibility of the package/project developer? Seems to me it always has been and still is. The only time it wasn't is when the user put the desired data type in a variable in advance and I think js itself highlighted the type and it wasn't a specific job of ts because the type was highlighted in advance.
  2. You live in a world where most developers still use @ts-ignore and type any, hammering away at type definitions, so this is only the developer's responsibility
  3. TypeScript is a tool, not a security guarantee . If it was a security guarantee - it wouldn't compile back to js. So I think - this feature should be considered as an additional feature and simplification of processing, not a guarantee
  4. It is impossible to describe all errors. There are expected and unexpected errors. As you say there are errors when closing a socket. Such a tool would at least allow to know the expected errors, because it is impossible to predict everything. Developers will need to describe the behaviour when an unexpected error occurs anyway, they won't just leave it alone (except for inexperienced developers) and handle it the way they want. This is the developer's responsibility.

You take it so seriously, as if it should be the silver bullet for all problems (literally for all bugs), although let me tell you that ts is far from perfect as it is. Sometimes when you release updates - I get a little shocked and have the thought: Wasn't this there before? It's on the surface, I thought it was the default and it was there.

Then even if everything got exception type information, how do you propose to solve the propagation problem? Function indirection is extremely common in JS, moreso than in many other languages. Without a solution here, we'd lack a mechanism to have this feature work anywhere where a closure is involved, and that happens all the time.

It's fine to look at a list of 20 issues and decide that 3 of them are bogus, but we can't just skip over the other 17.

Again, yes. I wonder how it should be too. I talked about that in my post because it's something to think about.

Well, the only solution I see is a dynamic analyser inside the lsp server. And how it should be implemented - I think you have much more experience in developing this tool (which is obvious) and you can find a good solution. Collect all throws and output the final type in catch. Ideally, analysing all throw and outputting to the final catch so that you don't even have to use throws

See for example how it is implemented in java. They love strict type safety. I think you can take an example from somewhere else or from other languages with strict typing

For example a normal person threw an extension (mini lsp) to
determine if there is an throw. https://plugins.jetbrains.com/plugin/23434-does-it-throw-
If one person threw such a thing, can't your team handle the task of adding it to lsp? Yes, it's a lot harder, but you don't have a one-person team

I think it will be a very good update for ts v6. Describing the full scope of the v6

@DScheglov
Copy link

@MrOxMasTer

The proposal to implement dynamic throw analysis in the IDE looks interesting but has rather limited applicability—it will improve DX, but in the end, it might not solve much. As soon as such an analyzer encounters throw something, where something is of type any or unknown, we won't get any useful information. And how should we handle throw Error, given that Error is the base class for most thrown exceptions? We would only get Error as a result (by the way, Node.js predominantly throws Error). So, what would we actually achieve? We would merely determine that some function throws something?

Alright, we could make the analyzer "not collapse" types—but in that case, we might end up with such a large list that we wouldn't know what to do with it.

In my opinion, all TS functions are default to throws unknown, because even:

const sum = (a: number, b: number) => a + b;

can throw an error.

@jakubnavratil
Copy link

For me, TS is mainly useful in two ways:

  1. Enforcing types / type checking
    I doubt this will ever be fully feature-complete with JS, because there’s no way to predict every runtime type JS allows. We still operate with a bit of the unknown. However, the main parts of app logic and library usage can still be typed in some way.

  2. Type suggestions
    As a dev, this is the main reason I use TS. While type checking and all of that is great, suggestions, global refactoring, and similar features are the most useful to me.

So what’s the point of typing exceptions? Is it to make everything 100% typed, even though we already know TS, by its nature, can’t fully type all of JS? No. But it can still type exceptions to some extent.

Most people in the discussion want error type suggestions. However, as stated many times, we currently lack any real tool for definitions or editor suggestions.
I really dislike the argument, “There are no error types for libraries, so we shouldn’t implement it.” When TS started, there were absolutely no TS types for any libraries, and many native JS functions weren’t properly typed. But TS created the necessary tools, and now nearly every library has TS types.

I see the same thing happening with error types. App logic could already use limited capabilities for typing errors, and libraries would join in over time. More features would come in the future as the ecosystem matures.

I often hear the argument, “You shouldn’t use exceptions in the first place, just return errors.” That’s all well and good, but in practice, we still use libraries that throw, or rely on native functions/expressions that can throw.

If I make an HTTP request using a library, I’d really like to know which specific exceptions it might throw, so I can handle them properly. Other exceptions can then be dealt with by a global handler, or locally with a catch-all. Right now, we simply don’t have the tools to define or consume those exceptions. Even having limited support (in editor) would be far more useful than relying solely on external library documentation.

@MrOxMasTer
Copy link

MrOxMasTer commented Mar 1, 2025

The proposal to implement dynamic throw analysis in the IDE looks interesting but has rather limited applicability—it will improve DX, but in the end, it might not solve much. As soon as such an analyzer encounters throw something, where something is of type any or unknown, we won't get any useful information. And how should we handle throw Error, given that Error is the base class for most thrown exceptions? We would only get Error result (by the way, Node.js predominantly throws Error). So, what would we actually achieve? We would merely determine that some function throws something?

frankly, I don't understand the logic behind the merger. It's always bothered me. Well, yes, it can be unknown so what? Does it exclude the fact that string or any other type can be returned? Instead of getting at least an approximation of what it could be and checking with typeof whether the variable is string? It seems to me that the type string | unknown is more in the concept of strict typing than just turning everything into unknown.
And it seems strange to me: how can you position yourself as a strict typing language if you have types any and unknown.
The unknown type would not exist in principle, I think, if catch were not unknown.

So for me TypeScript is primarily a tool for hints rather than a strict typing language, because the only thing they have in common is types

@DScheglov
Copy link

@MrOxMasTer

It seems to me that the type string | unknown is more in the concept of strict typing than just turning everything into unknown.

Could you please explain what you mean here? Types are not sets, but in this particular case, the union of sets works perfectly. If you receive something that is string | unknown, you receive exactly unknown, and knowing that it could be something else (e.g., string) doesn’t help you handle the received value.

So, if some IDE extension or even a language server defines the possible exceptions a function may throw, we will see either unknown or Error. And maybe we’ll get something like: "Function call that may throw"—but what are we supposed to do with that? Any function call can throw.

Actually, a solution for what you requested already exists. JSDoc allows developers to describe the errors that a function can throw:

/**
 * @throws {JwtVerificationError}
 */
export function verify<Payload = unknown>(
  jwt: string,
  secretOrPublicKey: string,
  options?: JWTVerificationOptions,
): Payload {
  // --- snip ---
}

And then on the function call we see the correspondent hint:

Image

Regarding error codes vs exceptions—this debate has a long history, and the conclusion is that both have their place.

For my projects, I concluded that for domain-level errors, I need error codes (i.e., the Result type), while for infrastructural errors, exceptions work perfectly. I can convert a caught exception into an error code (for example, DatabaseError thrown by pg in case of a unique index violation could be converted to the domain-level error code 'ERR_USERNAME_IS_ALREADY_IN_USE'), and vice versa if needed.

And it is not just a matter of strict type checking; it is also about code transparency and development experience.

@MrOxMasTer
Copy link

It seems to me that the type string | unknown is more in the concept of strict typing than just turning everything into unknown.

Could you please explain what you mean here? Types are not sets, but in this particular case, the union of sets works perfectly. If you receive something that is string | unknown, you receive exactly unknown, and knowing that it could be something else (e.g., string) doesn’t help you handle the received value.

From a merging point of view this works well, since string is part of unknown. But here's the thing: you see the type string | unknown. You realise that it might return string and I will for example process string first and then the unknown behaviour. At least what I know. At least I will have a choice to do that.

About Error and unknown - this solution is not very good, because it mixes expected errors and unexpected errors. If we look at it from this point of view, then yes, there is no point in typing errors at all, but that doesn't exclude the fact that it's inconvenient and we can't see at least something other than unknown. This unknown type is exactly what unexpected errors could be contained in.

In fact, it looks - it's hackneyed. From the point of view of strict typing - it explicitly forbids implicit conversions. But instead of prohibiting implicit transformations - ts made 2 types that can fit everyone. Like approaching the problem from behind. It implicitly converts everything next to unknown and any to those types.

@MrOxMasTer
Copy link

Actually, a solution for what you requested already exists. JSDoc allows developers to describe the errors that a function can throw:

/**

  • @throws {JwtVerificationError}
    */
    export function verify<Payload = unknown>(
    jwt: string,
    secretOrPublicKey: string,
    options?: JWTVerificationOptions,
    ): Payload {
    // --- snip ---
    }
    And then on the function call we see the correspondent hint:

Image

About JSDoc, probably yes, you can - but it looks too cumbersome.

I'm already using Effect-ts and I think error handling would clearly catch up with ts. Like the compiler from facebook for React Native which makes ts a compilable language.

Regarding error codes vs exceptions—this debate has a long history, and the conclusion is that both have their place.

For my projects, I concluded that for domain-level errors, I need error codes (i.e., the Result type), while for infrastructural errors, exceptions work perfectly. I can convert a caught exception into an error code (for example, DatabaseError thrown by pg in case of a unique index violation could be converted to the domain-level error code 'ERR_USERNAME_IS_ALREADY_IN_USE'), and vice versa if needed.

And it is not just a matter of strict type checking; it is also about code transparency and development experience.

Well, I agree with you here. Still, if we work within the same code base, it makes more sense to use exceptions.
At the http level, we go through a huge layer of abstraction and for standardization it is better to use codes.

For example, I don't need codes within the same code base, because I use next.js and I have everything within the same code base.

I honestly don't understand what this part was for.

@MrOxMasTer
Copy link

It seems to me that the type string | unknown is more in the concept of strict typing than just turning everything into unknown.

Could you please explain what you mean here? Types are not sets, but in this particular case, the union of sets works perfectly. If you receive something that is string | unknown, you receive exactly unknown, and knowing that it could be something else (e.g., string) doesn’t help you handle the received value.

So, if some IDE extension or even a language server defines the possible exceptions a function may throw, we will see either unknown or Error. And maybe we’ll get something like: "Function call that may throw"—but what are we supposed to do with that? Any function call can throw.

The problem with this merge is that if you merge this error, which inherits from Error, it just all collapses into a single Error

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Mar 3, 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.

Collect all throws and output the final type in catch. Ideally, analysing all throw and outputting to the final catch so that you don't even have to use throws

Just as a very very very first problem, not all relevant code is available for analysis!

I have been writing dozens of pages over the years here explaining why we don't think a good solution exists for this particular problem, outlining many different problems in this domain. We release highly-demanded features all the time; we are not opposed to finding solutions, and in fact delight in solving hard problems. We've spent years trying to come up with an answer to this problem and our current position is that one doesn't exist. Maybe tomorrow we find one, but what I'm trying to convey is that we really don't think we will and that is the current state of things. We can spend all our energy to keep looking for Bigfoot, or can go address real problems that have available solutions.

Well, the only solution I see is a dynamic analyser inside the lsp server. And how it should be implemented - I think you have much more experience in developing this tool (which is obvious) and you can find a good solution.

How are you in a better position than us to say that such a solution exists, yet in a worse position to find one?

@phaux
Copy link

phaux commented Mar 6, 2025

@RyanCavanaugh The solution exists and was described many times in this thread. Hegel already implemented such feature:

function assertPositiveNumber(value) {
    if (typeof value !== "number") {
        throw new TypeError("Given argument is not a number!");
    }
    if (value < 0) {
        throw new RangeError("Given number is not a positive number!");
    }
}

try {
    assertPositiveNumber(123);
} catch (error) {
    error // RangeError | TypeError
}

Just as a very very very first problem, not all relevant code is available for analysis!

So, the only "problem" is that there's no code already annotated with throw types, which is not a problem at all because they would be inferred the same way return types are inferred when missing, or fallback to unknown. But the latter is only the case if the type comes from a .d.ts file which was generated with current TypeScript. It'd need to be simply regenerated with the future TS version (with typed throws) and published again. I'm assuming lib.d.ts would get throw types as part of this feature.

Then even if everything got exception type information, how do you propose to solve the propagation problem? Function indirection is extremely common in JS, moreso than in many other languages. Without a solution here, we'd lack a mechanism to have this feature work anywhere where a closure is involved, and that happens all the time.

I don't understand what this paragrapth is about at all. It feels like @RyanCavanaugh is misunderstanding what is proposed here.

@DScheglov
Copy link

Pani @phaux ,

Actually hegel infers the type of error as RangeError | TypeError | unknown, you can check it in the playground

So, even with Hegel, we have an unknown type of error, which makes the rest of the information, such as RangeError | TypeError, useless for the compiler. Developers need to explicitly narrow the type of a caught exception or the reason for a rejected promise, and in any case, they need information about what should be caught and why. I believe that @throws is more than enough to achieve the same result as Exception | unknown.

@thw0rted
Copy link

thw0rted commented Mar 6, 2025

Since we cannot, for some reason, host this under "Discussions" where many pages of comments are easy to explore and search, and my previous comment is now old enough to be swallowed by the dreaded "Load More..." monster, I guess I'll just drop a link to it again:

#56365 (comment)

Time is a flat circle etc etc. We are back to the "TS is useful in two ways" number-two from above. We all like the "type hinting" TS gives your IDE but:

  • TS has no concept of "warnings", only "errors", and
  • unknown swallows the other types in a union, so
  • we can't have both useful type hints and sane catch-block behavior

Unfortunately if you want to completely understand why this is so, you have a couple hours of reading (and clicking "Load more...") ahead of you.

@phaux
Copy link

phaux commented Mar 6, 2025

@DScheglov

Actually hegel infers the type of error as RangeError | TypeError | unknown

That's probably because lib.d.ts doesn't have the throw types. I don't see any obstacles to soundly infer precise type in this case in particular.

It's true that the inferred type will get "polluted" with some error types that are obviously impossible (For example calling new RegExp("valid static regexp") will add SyntaxError to the thrown type union anyways), but that's a separate issue that can be resolved later by some new syntax maybe (like as but for thrown type or something).

Just implementing the feature the same way as in Hegel and adding throw types to standard lib definitions would be super-useful already. For example you could finally assume that at least error is of type Error, because TS could inspect that none of the subroutines does something stupid like throw "string", unless it does, in which case the type would flow all the way to your try-catch and get reported.

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