Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/server/src/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('builder', () => {

expect(applied).toBe(decorateMiddlewareSpy.mock.results[0]?.value)
expect(decorateMiddlewareSpy).toBeCalledTimes(1)
expect(decorateMiddlewareSpy).toBeCalledWith(mid)
expect(decorateMiddlewareSpy).toBeCalledWith(mid, def.errorMap)
})

it('.errors', () => {
Expand Down
14 changes: 11 additions & 3 deletions packages/server/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Context, MergedCurrentContext, MergedInitialContext } from './cont
import type { ORPCErrorConstructorMap } from './error'
import type { Lazy } from './lazy'
import type { AnyMiddleware, MapInputMiddleware, Middleware } from './middleware'
import type { DecoratedMiddleware } from './middleware-decorated'
import type { AnyDecoratedMiddleware, DecoratedMiddleware } from './middleware-decorated'
import type { ProcedureHandler } from './procedure'
import type { Router } from './router'
import type { EnhancedRouter, EnhanceRouterOptions } from './router-utils'
Expand Down Expand Up @@ -146,7 +146,7 @@ export class Builder<
middleware<UOutContext extends IntersectPick<TCurrentContext, UOutContext>, TInput, TOutput = any>( // = any here is important to make middleware can be used in any output by default
middleware: Middleware<TInitialContext, UOutContext, TInput, TOutput, ORPCErrorConstructorMap<TErrorMap>, TMeta>,
): DecoratedMiddleware<TInitialContext, UOutContext, TInput, TOutput, any, TMeta> { // any ensures middleware can used in any procedure
return decorateMiddleware(middleware)
return decorateMiddleware(middleware, this['~orpc'].errorMap)
}

/**
Expand Down Expand Up @@ -190,16 +190,24 @@ export class Builder<
>

use(
middleware: AnyMiddleware,
middleware: AnyMiddleware | AnyDecoratedMiddleware,
mapInput?: MapInputMiddleware<any, any>,
): BuilderWithMiddlewares<any, any, any, any, any, any> {
const mapped = mapInput
? decorateMiddleware(middleware).mapInput(mapInput)
: middleware

if (!('errorMap' in middleware) || !middleware.errorMap) {
return new Builder({
...this['~orpc'],
middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
}) as any
}

return new Builder({
...this['~orpc'],
middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
errorMap: mergeErrorMap(this['~orpc'].errorMap, middleware.errorMap),
}) as any
Comment on lines +200 to 211
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic inside the use method has some code duplication between the if and the else path. You can refactor it to be more concise and maintainable by creating the new builder definition object and conditionally adding the errorMap.

    const errorMap = ('errorMap' in middleware && middleware.errorMap)
      ? mergeErrorMap(this['~orpc'].errorMap, middleware.errorMap)
      : this['~orpc'].errorMap

    return new Builder({
      ...this['~orpc'],
      middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
      errorMap,
    }) as any

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's about whether the "errorMap" exist or not. The given solution is conciser but would make the object {errorMap: undefined} instead of omit the "errorMap" property. I don't know if it would affect anytime so I better make it safer.

}

Expand Down
31 changes: 27 additions & 4 deletions packages/server/src/middleware-decorated.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Meta } from '@orpc/contract'
import type { ErrorMap, Meta } from '@orpc/contract'
import type { IntersectPick } from '@orpc/shared'
import type { Context, MergedCurrentContext, MergedInitialContext } from './context'
import type { ORPCErrorConstructorMap } from './error'
Expand All @@ -12,6 +12,11 @@ export interface DecoratedMiddleware<
TErrorConstructorMap extends ORPCErrorConstructorMap<any>,
TMeta extends Meta,
> extends Middleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta> {
/**
* Error map associated with this middleware (if any)
* @internal
*/
errorMap?: ErrorMap
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your current implementation seem not "typesafe" at all, it only cover at runtime level while ignore the type level.

To fully support this, we require procedure inherit middleware's errors on both runtime and type levels when .use

And you should change the type of Middleware instead of DecoratedMiddleware

Copy link
Copy Markdown
Author

@QzCurious QzCurious Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For typesafe, do you mean we should be able to infer the errors type in handler? I do understand my implementation is not fulfilling it. But I want to fix the "defined for error thrown from middleware is false" thing first without affecting current behavior.

https://github.com/QzCurious/orpc/blob/d558620109300ba41f04984fe1713b9184d4ea7f/packages/server/tests/error-middleware-pattern.test.ts#L48-L71

Screen.Recording.2025-08-24.at.17.mp4

If we want it work as a whole, that is, both "defined=true" and "typesafe", I agree change Middleware would be the right way to it. Then, do you mind if I store the errors as a property of a middleware?
I tried to change type of Middleware as I did with DecoratedMiddleware but it could affect more places. Wish I can make it well.

Copy link
Copy Markdown
Member

@dinwwwh dinwwwh Aug 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You must make sure runtime errors and type errors are matching, if only merging at runtime the client can throw defined error more than the type describe -> unexpected behavior.

For reference here my implementation in the past: https://github.com/unnoq/orpc/pull/224 - but it faces some issue with contract-first

/**
* Change the expected input type by providing a map function.
*/
Expand Down Expand Up @@ -78,6 +83,8 @@ export interface DecoratedMiddleware<
>
}

export type AnyDecoratedMiddleware = DecoratedMiddleware<any, any, any, any, any, any>

export function decorateMiddleware<
TInContext extends Context,
TOutContext extends Context,
Expand All @@ -86,23 +93,39 @@ export function decorateMiddleware<
TErrorConstructorMap extends ORPCErrorConstructorMap<any>,
TMeta extends Meta,
>(
middleware: Middleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>,
middleware: Middleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>
| DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>,
errorMap?: ErrorMap,
): DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta> {
const decorated = ((...args) => middleware(...args)) as DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>

// Attach error map if provided
if (errorMap) {
decorated.errorMap = errorMap
}
Comment on lines +102 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The decorateMiddleware function doesn't preserve the errorMap if the passed middleware is already a DecoratedMiddleware and no new errorMap is provided. This can lead to the errorMap being lost when chaining methods like .mapInput(). To fix this, you should check for an existing errorMap on the middleware and preserve it.

  // Attach error map if provided, preserving existing one if present
  if (errorMap) {
    decorated.errorMap = errorMap
  } else if ('errorMap' in middleware && (middleware as AnyDecoratedMiddleware).errorMap) {
    decorated.errorMap = (middleware as AnyDecoratedMiddleware).errorMap
  }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about this? I want it typed.

export function decorateMiddleware<
  TInContext extends Context,
  TOutContext extends Context,
  TInput,
  TOutput,
  TErrorConstructorMap extends ORPCErrorConstructorMap<any>,
  TMeta extends Meta,
>(
  middleware: Middleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>
            | DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>,
  errorMap?: ErrorMap,
): DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta> {
  const decorated = ((...args) => middleware(...args)) as DecoratedMiddleware<TInContext, TOutContext, TInput, TOutput, TErrorConstructorMap, TMeta>

  // Attach error map if provided
  if (errorMap) {
    decorated.errorMap = errorMap
  }
  if ('errorMap' in middleware) {
    decorated.errorMap = middleware.errorMap
  }

if ('errorMap' in middleware) {
decorated.errorMap = middleware.errorMap
}

decorated.mapInput = (mapInput) => {
const mapped = decorateMiddleware(
(options, input, ...rest) => middleware(options as any, mapInput(input as any), ...rest as [any]),
decorated.errorMap, // Preserve error map
)

return mapped as any
}

decorated.concat = (concatMiddleware: AnyMiddleware, mapInput?: MapInputMiddleware<any, any>) => {
decorated.concat = (concatMiddleware: AnyMiddleware | AnyDecoratedMiddleware, mapInput?: MapInputMiddleware<any, any>) => {
const mapped = mapInput
? decorateMiddleware(concatMiddleware).mapInput(mapInput)
: concatMiddleware

const combinedErrorMap = {
...decorated.errorMap,
...('errorMap' in concatMiddleware ? concatMiddleware.errorMap : undefined),
}

const concatted = decorateMiddleware((options, input, output, ...rest) => {
const merged = middleware({
...options,
Expand All @@ -114,7 +137,7 @@ export function decorateMiddleware<
} as any, input as any, output as any, ...rest)

return merged
})
}, combinedErrorMap)

return concatted as any
}
Expand Down
11 changes: 10 additions & 1 deletion packages/server/src/procedure-decorated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { IntersectPick, MaybeOptionalOptions } from '@orpc/shared'
import type { Context, MergedCurrentContext, MergedInitialContext } from './context'
import type { ORPCErrorConstructorMap } from './error'
import type { AnyMiddleware, MapInputMiddleware, Middleware } from './middleware'
import type { AnyDecoratedMiddleware } from './middleware-decorated'
import type { ProcedureActionableClient } from './procedure-action'
import type { CreateProcedureClientOptions, ProcedureClient } from './procedure-client'
import { mergeErrorMap, mergeMeta, mergeRoute } from '@orpc/contract'
Expand Down Expand Up @@ -136,14 +137,22 @@ export class DecoratedProcedure<
TMeta
>

use(middleware: AnyMiddleware, mapInput?: MapInputMiddleware<any, any>): DecoratedProcedure<any, any, any, any, any, any> {
use(middleware: AnyMiddleware | AnyDecoratedMiddleware, mapInput?: MapInputMiddleware<any, any>): DecoratedProcedure<any, any, any, any, any, any> {
const mapped = mapInput
? decorateMiddleware(middleware).mapInput(mapInput)
: middleware

if (!('errorMap' in middleware) || !middleware.errorMap) {
return new DecoratedProcedure({
...this['~orpc'],
middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
})
}

return new DecoratedProcedure({
...this['~orpc'],
middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
errorMap: mergeErrorMap(this['~orpc'].errorMap, middleware.errorMap),
})
Comment on lines +145 to 156
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the use method in builder.ts, this implementation has duplicated code. It can be refactored for better readability and maintainability.

    const errorMap = ('errorMap' in middleware && middleware.errorMap)
      ? mergeErrorMap(this['~orpc'].errorMap, middleware.errorMap)
      : this['~orpc'].errorMap

    return new DecoratedProcedure({
      ...this['~orpc'],
      middlewares: addMiddleware(this['~orpc'].middlewares, mapped),
      errorMap,
    })

}

Expand Down
80 changes: 80 additions & 0 deletions packages/server/tests/error-middleware-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createSafeClient } from '@orpc/client'
import { createRouterClient, os } from '../src'

describe('error middleware patterns', () => {
it('should have defined=true when using original supported pattern (same base for middleware and procedure)', async () => {
// ✅ CORRECT: Define errors on base, use same base for both middleware and procedure
const base = os.errors({
UNAUTHORIZED: {},
})

const middleware = base.middleware(async ({ next, context, errors }) => {
throw errors.UNAUTHORIZED()
})

const router = base.use(middleware).handler(async () => {})

const client = createSafeClient(createRouterClient(router))
const [error,, isDefined] = await client()

// Should have defined=true because error was thrown from defined error map
expect((error as any).defined).toBe(true)
expect(isDefined).toBe(true)
expect((error as any).code).toBe('UNAUTHORIZED')
})

it('should have defined=true with automatic error map merging (new behavior)', async () => {
// ✅ NEW BEHAVIOR: Middleware error maps are automatically merged
const middleware = os.errors({
UNAUTHORIZED: {},
}).middleware(async ({ next, context, errors }) => {
throw errors.UNAUTHORIZED()
})

// Using base os (no errors) but middleware error map should be automatically merged
const router = os.use(middleware).handler(async () => {})

const client = createSafeClient(createRouterClient(router))
const [error,, isDefined] = await client()

// Should now have defined=true because middleware error map gets merged automatically
expect((error as any).defined).toBe(true)
expect(isDefined).toBe(true)
expect((error as any).code).toBe('UNAUTHORIZED')
// Verify the error map was merged
expect(router['~orpc'].errorMap).toHaveProperty('UNAUTHORIZED')
})

it('should merge errors from different sources correctly', async () => {
const middleware = os.errors({
UNAUTHORIZED: {},
}).middleware(async ({ next, context, errors }) => {
// Don't throw, just continue to test error map merging
return next({ context })
})
const router = os
.use(middleware)
.errors({ NOT_FOUND: {} })
.handler(async ({ errors }) => {
// Should have access to both UNAUTHORIZED and NOT_FOUND
expect('UNAUTHORIZED' in errors).toBe(true)
expect('NOT_FOUND' in errors).toBe(true)

// @ts-expect-error TODO: Currently, errors defined in middleware is not inferred into the procedure
const unauthorizedError = errors.UNAUTHORIZED()
const notFoundError = errors.NOT_FOUND()

expect(unauthorizedError.defined).toBe(true)
expect(notFoundError.defined).toBe(true)

throw notFoundError
})

const client = createSafeClient(createRouterClient(router))
const [error,, isDefined] = await client()

expect((error as any).defined).toBe(true)
expect(isDefined).toBe(true)
expect((error as any).code).toBe('NOT_FOUND')
})
})