Repository: fastify/fastify

Issue:
Incorrect error type in `setErrorHandler`
### Prerequisites

- [x] I have written a descriptive issue title
- [x] I have searched existing issues to ensure the bug has not already been reported


### Fastify version

5.2.2

### Plugin version

_No response_

### Node.js version

22.14.0

### Operating system

Linux

### Operating system version (i.e. 20.04, 11.3, 10)

Manjaro (rolling release)

### Description

Route handlers are allowed to throw errors that can then be handled by a global or plugin-scoped error handler (`.setErrorHandler()`). The problem is that the `setErrorHandler` function always assumes the error originates from Fastify internals and automatically infers `fastify.FastifyError` as the error type. If the error was thrown from a route handler, this assumption is most likely incorrect and can result in a `TypeError`. Fastify should either infer the error type to `unknown` (which is the behavior or `catch` blocks), or wrap any error thrown from a route handler in its own expected format.

The minimal reproducible example demonstrates how Typescript fails to prevent a TypeError which is then shown to the client.

Here is the resulting HTTP response body:
```json
{
	"statusCode": 500,
	"error": "Internal Server Error",
	"message": "Cannot read properties of undefined (reading 'toUpperCase')"
}
```

### Link to code that reproduces the bug

https://github.com/paulius-valiunas/fastify-error-repro

### Expected Behavior

```
src/index.ts:4:21 - error TS2339: Property 'code' does not exist on type 'unknown'.

4   console.log(error.code); // prints undefined
                      ~~~~


Found 1 error in src/index.ts:4
```


Discussion:
Thanks for reporting!
Would you like to send a Pull Request to address this issue? Remember to add unit tests.
I'm not sure what would be your preferred way to solve this. The way I see it, you could either change the types of `errorHandler` and `setErrorHandler` to use `unknown` for the error objects, or find a way to wrap any kind of error in a `FastifyError`. The former would technically be a breaking change and would break some Typescript code that was compiling fine until now, but I guess it could be released as a patch considering it was a typing bug in the first place. The latter would be more complex, and would break some Javascript at runtime because the handlers would receive different error objects, but it wouldn't be breaking from Typescript perspective.
Hi @Eomm  ✨,
I’d love to take this up and contribute a fix. From the discussion, I see two possible paths:

Changing the typings to use unknown (breaking for some TS users, but more correct), or

Wrapping thrown errors into a FastifyError (runtime consistent, but slightly more invasive).

Could you please guide me on which approach would be more aligned with Fastify’s vision? 🌱
Either way, I’ll make sure to cover it with tests.

Thank you for being so open to contributors — it means a lot. 💚



Patch:
diff --git a/lib/error-handler.js b/lib/error-handler.js
index 25bb90cb97f..03d5cab36ee 100644
--- a/lib/error-handler.js
+++ b/lib/error-handler.js
@@ -82,7 +82,7 @@ function handleError (reply, error, cb) {
 function defaultErrorHandler (error, request, reply) {
   setErrorHeaders(error, reply)
   if (!reply[kReplyHasStatusCode] || reply.statusCode === 200) {
-    const statusCode = error.statusCode || error.status
+    const statusCode = error && (error.statusCode || error.status)
     reply.code(statusCode >= 400 ? statusCode : 500)
   }
   if (reply.statusCode < 500) {
diff --git a/types/instance.d.ts b/types/instance.d.ts
index 0173dff9ade..f940ad10509 100644
--- a/types/instance.d.ts
+++ b/types/instance.d.ts
@@ -464,12 +464,12 @@ export interface FastifyInstance<
   /**
    * Fastify default error handler
    */
-  errorHandler: (error: FastifyError, request: FastifyRequest, reply: FastifyReply) => void;
+  errorHandler: <TError = unknown>(error: TError, request: FastifyRequest, reply: FastifyReply) => void;
 
   /**
    * Set a function that will be invoked whenever an exception is thrown during the request lifecycle.
    */
-  setErrorHandler<TError extends Error = FastifyError, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault>(
+  setErrorHandler<TError = unknown, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault>(
     handler: (this: FastifyInstance<RawServer, RawRequest, RawReply, Logger, TypeProvider>, error: TError, request: FastifyRequest<RouteGeneric, RawServer, RawRequest, SchemaCompiler, TypeProvider>, reply: FastifyReply<RouteGeneric, RawServer, RawRequest, RawReply, ContextConfigDefault, SchemaCompiler, TypeProvider>) => any | Promise<any>
   ): FastifyInstance<RawServer, RawRequest, RawReply, Logger, TypeProvider>;
 


Test patch:
diff --git a/test/500s.test.js b/test/500s.test.js
index e6ee7e631d0..846eefbab9b 100644
--- a/test/500s.test.js
+++ b/test/500s.test.js
@@ -3,6 +3,7 @@
 const { test } = require('node:test')
 const Fastify = require('..')
 const symbols = require('../lib/symbols.js')
+const { FastifyError } = require('@fastify/error')
 
 test('default 500', (t, done) => {
   t.plan(4)
@@ -30,6 +31,94 @@ test('default 500', (t, done) => {
   })
 })
 
+test('default 500 with non-error string', (t, done) => {
+  t.plan(4)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/', function (req, reply) {
+    throw 'kaboom' // eslint-disable-line no-throw-literal
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8')
+    t.assert.deepStrictEqual(res.payload, 'kaboom')
+    done()
+  })
+})
+
+test('default 500 with non-error symbol', (t, done) => {
+  t.plan(4)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/', function (req, reply) {
+    throw Symbol('error')
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8')
+    t.assert.deepStrictEqual(res.payload, '')
+    done()
+  })
+})
+
+test('default 500 with non-error false', (t, done) => {
+  t.plan(4)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/', function (req, reply) {
+    throw false // eslint-disable-line no-throw-literal
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8')
+    t.assert.deepStrictEqual(res.payload, 'false')
+    done()
+  })
+})
+
+test('default 500 with non-error null', (t, done) => {
+  t.plan(4)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/', function (req, reply) {
+    throw null // eslint-disable-line no-throw-literal
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8')
+    t.assert.deepStrictEqual(res.payload, 'null')
+    done()
+  })
+})
+
 test('custom 500', (t, done) => {
   t.plan(6)
 
@@ -229,3 +318,105 @@ test('catch synchronous errors', (t, done) => {
     done()
   })
 })
+
+test('custom 500 with non-error and custom errorHandler', (t, done) => {
+  t.plan(6)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/', function (req, reply) {
+    throw 'kaboom' // eslint-disable-line no-throw-literal
+  })
+
+  fastify.setErrorHandler(function (err, request, reply) {
+    t.assert.ok(typeof request === 'object')
+    t.assert.ok(request instanceof fastify[symbols.kRequest].parent)
+    reply
+      .code(500)
+      .type('text/plain')
+      .send('an error happened: ' + err.message)
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'text/plain')
+    t.assert.deepStrictEqual(res.payload, 'an error happened: undefined')
+    done()
+  })
+})
+
+test('custom 500 with FastifyError detection', (t, done) => {
+  t.plan(18)
+
+  const fastify = Fastify()
+  t.after(() => fastify.close())
+
+  fastify.get('/string', function (req, reply) {
+    throw 'kaboom' // eslint-disable-line no-throw-literal
+  })
+
+  fastify.get('/native-error', function (req, reply) {
+    throw new Error('kaboom')
+  })
+
+  fastify.get('/fastify-error', function (req, reply) {
+    throw new FastifyError('kaboom')
+  })
+
+  fastify.setErrorHandler(function (err, request, reply) {
+    t.assert.ok(typeof request === 'object')
+    t.assert.ok(request instanceof fastify[symbols.kRequest].parent)
+    if (err instanceof FastifyError) {
+      reply
+        .code(500)
+        .type('text/plain')
+        .send('FastifyError thrown')
+    } else if (err instanceof Error) {
+      reply
+        .code(500)
+        .type('text/plain')
+        .send('Error thrown')
+    } else {
+      reply
+        .code(500)
+        .type('text/plain')
+        .send('Primitive thrown')
+    }
+  })
+
+  fastify.inject({
+    method: 'GET',
+    url: '/string'
+  }, (err, res) => {
+    t.assert.ifError(err)
+    t.assert.strictEqual(res.statusCode, 500)
+    t.assert.strictEqual(res.headers['content-type'], 'text/plain')
+    t.assert.deepStrictEqual(res.payload, 'Primitive thrown')
+
+    fastify.inject({
+      method: 'GET',
+      url: '/native-error'
+    }, (err, res) => {
+      t.assert.ifError(err)
+      t.assert.strictEqual(res.statusCode, 500)
+      t.assert.strictEqual(res.headers['content-type'], 'text/plain')
+      t.assert.deepStrictEqual(res.payload, 'Error thrown')
+
+      fastify.inject({
+        method: 'GET',
+        url: '/fastify-error'
+      }, (err, res) => {
+        t.assert.ifError(err)
+        t.assert.strictEqual(res.statusCode, 500)
+        t.assert.strictEqual(res.headers['content-type'], 'text/plain')
+        t.assert.deepStrictEqual(res.payload, 'FastifyError thrown')
+        done()
+      })
+    })
+  })
+})
diff --git a/test/types/instance.test-d.ts b/test/types/instance.test-d.ts
index 3780ebd2ba1..e59c8d00d62 100644
--- a/test/types/instance.test-d.ts
+++ b/test/types/instance.test-d.ts
@@ -42,7 +42,7 @@ expectType<string[]>(server.supportedMethods)
 
 expectAssignable<FastifyInstance>(
   server.setErrorHandler(function (error, request, reply) {
-    expectType<FastifyError>(error)
+    expectType<unknown>(error)
     expectAssignable<FastifyInstance>(this)
   })
 )
@@ -150,12 +150,6 @@ server.setNotFoundHandler(function (_, reply) {
   return reply.send('')
 })
 
-function invalidErrorHandler (error: number) {
-  if (error) throw error
-}
-
-expectError(server.setErrorHandler(invalidErrorHandler))
-
 server.setSchemaController({
   bucket: (parentSchemas: unknown) => {
     return {
@@ -253,7 +247,7 @@ expectAssignable<void>(server.routing({} as RawRequestDefaultExpression, {} as R
 expectType<FastifyInstance>(fastify().get<RouteGenericInterface, { contextKey: string }>('/', {
   handler: () => {},
   errorHandler: (error, request, reply) => {
-    expectAssignable<FastifyError>(error)
+    expectAssignable<unknown>(error)
     expectAssignable<FastifyRequest>(request)
     expectAssignable<{ contextKey: string }>(request.routeOptions.config)
     expectAssignable<FastifyReply>(reply)