Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export default defineConfig({
collapsed: true,
items: [
{ text: 'Dedupe Middleware', link: '/docs/best-practices/dedupe-middleware' },
{ text: 'No Throw Literal', link: '/docs/best-practices/no-throw-literal' },
],
},
{
Expand Down
57 changes: 57 additions & 0 deletions apps/content/docs/best-practices/no-throw-literal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: No Throw Literal
description: Always throw `Error` instances instead of literal values.
---

# No Throw Literal

In JavaScript, you can throw any value, but it's best to throw only `Error` instances.

```ts
// eslint-disable-next-line no-throw-literal
throw 'error' // ✗ avoid
throw new Error('error') // ✓ recommended
```

:::info
oRPC treats thrown `Error` instances as best practice by default, as recommended by the [JavaScript Standard Style](https://standardjs.com/rules.html#throw-new-error-old-style).
:::

## Configuration

Customize oRPC's behavior by setting `throwableError` in the `Registry`:

```ts
declare module '@orpc/server' { // or '@orpc/contract', or '@orpc/client'
interface Registry {
throwableError: Error // [!code highlight]
}
}
```

:::info
Avoid using `any` or `unknown` for `throwableError` because doing so prevents the client from inferring [type-safe errors](/docs/client/error-handling#using-safe-and-isdefinederror). Instead, use `null | undefined | {}` (equivalent to `unknown`) for stricter error type inference.
:::

:::tip
If you configure `throwableError` as `null | undefined | {}`, adjust your code to check the `success` property:

```ts
const { error, data, success } = await safe(client('input'))

if (!success) {
if (isDefinedError(error)) {
// handle type-safe error
}
// handle other errors
}
else {
// handle success
}
```

:::

## Bonus

If you use ESLint, enable the [no-throw-literal](https://eslint.org/docs/rules/no-throw-literal) rule to enforce throwing only `Error` instances.
6 changes: 3 additions & 3 deletions packages/client/src/adapters/standard/link.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Interceptor } from '@orpc/shared'
import type { Interceptor, ThrowableError } from '@orpc/shared'
import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server'
import type { ClientContext, ClientLink, ClientOptions } from '../../types'
import type { StandardLinkClient, StandardLinkCodec } from './types'
Expand All @@ -11,8 +11,8 @@ export interface StandardLinkPlugin<T extends ClientContext> {
}

export interface StandardLinkOptions<T extends ClientContext> {
interceptors?: Interceptor<{ path: readonly string[], input: unknown, options: ClientOptions<T> }, unknown, unknown>[]
clientInterceptors?: Interceptor<{ request: StandardRequest }, StandardLazyResponse, unknown>[]
interceptors?: Interceptor<{ path: readonly string[], input: unknown, options: ClientOptions<T> }, unknown, ThrowableError>[]
clientInterceptors?: Interceptor<{ request: StandardRequest }, StandardLazyResponse, ThrowableError>[]
plugins?: StandardLinkPlugin<T>[]
}

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from './types'
export * from './utils'

export { onError, onFinish, onStart, onSuccess } from '@orpc/shared'
export type { Registry, ThrowableError } from '@orpc/shared'
export { ErrorEvent } from '@orpc/standard-server'
4 changes: 2 additions & 2 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export type ClientRest<TClientContext extends ClientContext, TInput> = Record<ne
: [input: TInput, options?: FriendlyClientOptions<TClientContext>]
: [input: TInput, options: FriendlyClientOptions<TClientContext>]

export type ClientPromiseResult<TOutput, TError extends Error> = PromiseWithError<TOutput, TError>
export type ClientPromiseResult<TOutput, TError> = PromiseWithError<TOutput, TError>

export interface Client<TClientContext extends ClientContext, TInput, TOutput, TError extends Error> {
export interface Client<TClientContext extends ClientContext, TInput, TOutput, TError> {
(...rest: ClientRest<TClientContext, TInput>): ClientPromiseResult<TOutput, TError>
}

Expand Down
15 changes: 11 additions & 4 deletions packages/client/src/utils.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ describe('safe', async () => {
const client = {} as Client<ClientContext, string, number, Error | ORPCError<'BAD_GATEWAY', { val: string }>>

it('tuple style', async () => {
const [error, data, isDefined] = await safe(client('123'))
const [error, data, isDefined, success] = await safe(client('123'))

if (error) {
if (error || !success) {
expectTypeOf(error).toEqualTypeOf<Error | ORPCError<'BAD_GATEWAY', { val: string }>>()
expectTypeOf(data).toEqualTypeOf<undefined>()
expectTypeOf(isDefined).toEqualTypeOf<boolean>()
Expand All @@ -33,9 +33,9 @@ describe('safe', async () => {
})

it('object style', async () => {
const { error, data, isDefined } = await safe(client('123'))
const { error, data, isDefined, success } = await safe(client('123'))

if (error) {
if (error || !success) {
expectTypeOf(error).toEqualTypeOf<Error | ORPCError<'BAD_GATEWAY', { val: string }>>()
expectTypeOf(data).toEqualTypeOf<undefined>()
expectTypeOf(isDefined).toEqualTypeOf<boolean>()
Expand All @@ -57,4 +57,11 @@ describe('safe', async () => {
expectTypeOf(isDefined).toEqualTypeOf<false>()
}
})

it('can catch Promise', async () => {
const { error, data } = await safe({} as Promise<number>)

expectTypeOf(error).toEqualTypeOf<Error | null>()
expectTypeOf(data).toEqualTypeOf<number | undefined >()
})
})
16 changes: 8 additions & 8 deletions packages/client/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ import { resolveFriendlyClientOptions, safe } from './utils'

it('safe', async () => {
const r1 = await safe(Promise.resolve(1))
expect([...r1]).toEqual([null, 1, false])
expect({ ...r1 }).toEqual(expect.objectContaining({ error: null, data: 1, isDefined: false }))
expect([...r1]).toEqual([null, 1, false, true])
expect({ ...r1 }).toEqual(expect.objectContaining({ error: null, data: 1, isDefined: false, success: true }))

const e2 = new Error('error')
const r2 = await safe(Promise.reject(e2))
expect([...r2]).toEqual([e2, undefined, false])
expect({ ...r2 }).toEqual(expect.objectContaining({ error: e2, data: undefined, isDefined: false }))
expect([...r2]).toEqual([e2, undefined, false, false])
expect({ ...r2 }).toEqual(expect.objectContaining({ error: e2, data: undefined, isDefined: false, success: false }))

const e3 = new ORPCError('BAD_GATEWAY', { defined: true })
const r3 = await safe(Promise.reject(e3))
expect([...r3]).toEqual([e3, undefined, true])
expect({ ...r3 }).toEqual(expect.objectContaining({ error: e3, data: undefined, isDefined: true }))
expect([...r3]).toEqual([e3, undefined, true, false])
expect({ ...r3 }).toEqual(expect.objectContaining({ error: e3, data: undefined, isDefined: true, success: false }))

const e4 = new ORPCError('BAD_GATEWAY')
const r4 = await safe(Promise.reject(e4))
expect([...r4]).toEqual([e4, undefined, false])
expect({ ...r4 }).toEqual(expect.objectContaining({ error: e4, data: undefined, isDefined: false }))
expect([...r4]).toEqual([e4, undefined, false, false])
expect({ ...r4 }).toEqual(expect.objectContaining({ error: e4, data: undefined, isDefined: false, success: false }))
})

it('resolveFriendlyClientOptions', () => {
Expand Down
29 changes: 15 additions & 14 deletions packages/client/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
import type { ThrowableError } from '@orpc/shared'
import type { ORPCError } from './error'
import type { ClientContext, ClientOptions, ClientPromiseResult, FriendlyClientOptions } from './types'
import { isDefinedError } from './error'

export type SafeResult<TOutput, TError extends Error> =
| [error: null, data: TOutput, isDefined: false]
& { error: null, data: TOutput, isDefined: false }
| [error: Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false]
& { error: Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false }
| [error: Extract<TError, ORPCError<any, any>>, data: undefined, isDefined: true]
& { error: Extract<TError, ORPCError<any, any>>, data: undefined, isDefined: true }
export type SafeResult<TOutput, TError> =
| [error: null, data: TOutput, isDefined: false, success: true]
& { error: null, data: TOutput, isDefined: false, success: true }
| [error: Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false, success: false]
& { error: Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false, success: false }
| [error: Extract<TError, ORPCError<any, any>>, data: undefined, isDefined: true, success: false]
& { error: Extract<TError, ORPCError<any, any>>, data: undefined, isDefined: true, success: false }

export async function safe<TOutput, TError extends Error>(promise: ClientPromiseResult<TOutput, TError>): Promise<SafeResult<TOutput, TError>> {
export async function safe<TOutput, TError = ThrowableError>(promise: ClientPromiseResult<TOutput, TError>): Promise<SafeResult<TOutput, TError>> {
try {
const output = await promise
return Object.assign(
[null, output, false] satisfies [null, TOutput, false],
{ error: null, data: output, isDefined: false as const },
[null, output, false, true] satisfies [null, TOutput, false, true],
{ error: null, data: output, isDefined: false as const, success: true as const },
)
}
catch (e) {
const error = e as TError

if (isDefinedError(error)) {
return Object.assign(
[error, undefined, true] satisfies [typeof error, undefined, true],
{ error, data: undefined, isDefined: true as const },
[error, undefined, true, false] satisfies [typeof error, undefined, true, false],
{ error, data: undefined, isDefined: true as const, success: false as const },
)
}

return Object.assign(
[error as Exclude<TError, ORPCError<any, any>>, undefined, false] satisfies [Exclude<TError, ORPCError<any, any>>, undefined, false],
{ error: error as Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false as const },
[error as Exclude<TError, ORPCError<any, any>>, undefined, false, false] satisfies [Exclude<TError, ORPCError<any, any>>, undefined, false, false],
{ error: error as Exclude<TError, ORPCError<any, any>>, data: undefined, isDefined: false as const, success: false as const },
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/contract/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ORPCError, ORPCErrorCode } from '@orpc/client'
import type { ThrowableError } from '@orpc/shared'
import type { AnySchema, InferSchemaOutput, Schema, SchemaIssue } from './schema'

export interface ValidationErrorOptions extends ErrorOptions {
Expand Down Expand Up @@ -40,4 +41,4 @@ export type ORPCErrorFromErrorMap<TErrorMap extends ErrorMap> = {
: never
}[keyof TErrorMap]

export type ErrorFromErrorMap<TErrorMap extends ErrorMap> = Error | ORPCErrorFromErrorMap<TErrorMap>
export type ErrorFromErrorMap<TErrorMap extends ErrorMap> = ORPCErrorFromErrorMap<TErrorMap> | ThrowableError
1 change: 1 addition & 0 deletions packages/contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './router-utils'
export * from './schema'

export { ORPCError } from '@orpc/client'
export type { Registry, ThrowableError } from '@orpc/shared'
4 changes: 2 additions & 2 deletions packages/react-query/src/procedure-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { InfiniteData } from '@tanstack/react-query'
import type { InfiniteOptionsBase, InfiniteOptionsIn, MutationOptions, MutationOptionsIn, QueryOptionsBase, QueryOptionsIn } from './types'
import { buildKey } from './key'

export interface ProcedureUtils<TClientContext extends ClientContext, TInput, TOutput, TError extends Error> {
export interface ProcedureUtils<TClientContext extends ClientContext, TInput, TOutput, TError> {
call: Client<TClientContext, TInput, TOutput, TError>

queryOptions<U, USelectData = TOutput>(
Expand All @@ -26,7 +26,7 @@ export interface CreateProcedureUtilsOptions {
path: string[]
}

export function createProcedureUtils<TClientContext extends ClientContext, TInput, TOutput, TError extends Error>(
export function createProcedureUtils<TClientContext extends ClientContext, TInput, TOutput, TError>(
client: Client<TClientContext, TInput, TOutput, TError>,
options: CreateProcedureUtilsOptions,
): ProcedureUtils<TClientContext, TInput, TOutput, TError> {
Expand Down
12 changes: 6 additions & 6 deletions packages/react-query/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,30 @@ import type { ClientContext } from '@orpc/client'
import type { SetOptional } from '@orpc/shared'
import type { QueryFunctionContext, QueryKey, UseInfiniteQueryOptions, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'

export type QueryOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError extends Error, TSelectData> =
export type QueryOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TSelectData> =
& (undefined extends TInput ? { input?: TInput } : { input: TInput })
& (Record<never, never> extends TClientContext ? { context?: TClientContext } : { context: TClientContext })
& SetOptional<UseQueryOptions<TOutput, TError, TSelectData>, 'queryKey'>

export interface QueryOptionsBase<TOutput, TError extends Error> {
export interface QueryOptionsBase<TOutput, TError> {
queryKey: QueryKey
queryFn(ctx: QueryFunctionContext): Promise<TOutput>
retry?(failureCount: number, error: TError): boolean // this make tanstack can infer the TError type
}

export type InfiniteOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError extends Error, TSelectData, TPageParam> =
export type InfiniteOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TSelectData, TPageParam> =
& { input: (pageParam: TPageParam) => TInput }
& (Record<never, never> extends TClientContext ? { context?: TClientContext } : { context: TClientContext })
& SetOptional<UseInfiniteQueryOptions<TOutput, TError, TSelectData, TOutput, QueryKey, TPageParam>, 'queryKey'>

export interface InfiniteOptionsBase<TOutput, TError extends Error, TPageParam> {
export interface InfiniteOptionsBase<TOutput, TError, TPageParam> {
queryKey: QueryKey
queryFn(ctx: QueryFunctionContext<QueryKey, TPageParam>): Promise<TOutput>
retry?(failureCount: number, error: TError): boolean // this make tanstack can infer the TError type
}

export type MutationOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError extends Error, TMutationContext> =
export type MutationOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TMutationContext> =
& (Record<never, never> extends TClientContext ? { context?: TClientContext } : { context: TClientContext })
& MutationOptions<TInput, TOutput, TError, TMutationContext>

export type MutationOptions<TInput, TOutput, TError extends Error, TMutationContext> = UseMutationOptions<TOutput, TError, TInput, TMutationContext>
export type MutationOptions<TInput, TOutput, TError, TMutationContext> = UseMutationOptions<TOutput, TError, TInput, TMutationContext>
16 changes: 8 additions & 8 deletions packages/react/src/hooks/action-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@ import { ORPCError, safe } from '@orpc/client'
import { intercept, type Interceptor, toArray } from '@orpc/shared'
import { useCallback, useMemo, useState } from 'react'

export interface UseServerActionOptions<TInput, TOutput, TError extends Error> {
export interface UseServerActionOptions<TInput, TOutput, TError> {
interceptors?: Interceptor<{ input: TInput }, TOutput, TError>[]
}

export interface UseServerActionExecuteOptions<TInput, TOutput, TError extends Error> extends Pick<UseServerActionOptions<TInput, TOutput, TError>, 'interceptors'> {
export interface UseServerActionExecuteOptions<TInput, TOutput, TError> extends Pick<UseServerActionOptions<TInput, TOutput, TError>, 'interceptors'> {
}

export type UseServerActionExecuteRest<TInput, TOutput, TError extends Error> =
export type UseServerActionExecuteRest<TInput, TOutput, TError> =
undefined extends TInput
? [input?: TInput, options?: UseServerActionExecuteOptions<TInput, TOutput, TError>]
: [input: TInput, options?: UseServerActionExecuteOptions<TInput, TOutput, TError>]

export interface UseServerActionResultBase<TInput, TOutput, TError extends Error> {
export interface UseServerActionResultBase<TInput, TOutput, TError> {
reset: () => void
execute: (...rest: UseServerActionExecuteRest<TInput, TOutput, TError>) => Promise<SafeResult<TOutput, TError>>
}

export interface UseServerActionIdleResult<TInput, TOutput, TError extends Error> extends UseServerActionResultBase<TInput, TOutput, TError> {
export interface UseServerActionIdleResult<TInput, TOutput, TError> extends UseServerActionResultBase<TInput, TOutput, TError> {
input: undefined
data: undefined
error: null
Expand All @@ -33,7 +33,7 @@ export interface UseServerActionIdleResult<TInput, TOutput, TError extends Error
executedAt: undefined
}

export interface UseServerActionPendingResult<TInput, TOutput, TError extends Error> extends UseServerActionResultBase<TInput, TOutput, TError> {
export interface UseServerActionPendingResult<TInput, TOutput, TError> extends UseServerActionResultBase<TInput, TOutput, TError> {
input: TInput
data: undefined
error: null
Expand All @@ -45,7 +45,7 @@ export interface UseServerActionPendingResult<TInput, TOutput, TError extends Er
executedAt: Date
}

export interface UseServerActionSuccessResult<TInput, TOutput, TError extends Error> extends UseServerActionResultBase<TInput, TOutput, TError> {
export interface UseServerActionSuccessResult<TInput, TOutput, TError> extends UseServerActionResultBase<TInput, TOutput, TError> {
input: TInput
data: TOutput
error: null
Expand All @@ -57,7 +57,7 @@ export interface UseServerActionSuccessResult<TInput, TOutput, TError extends Er
executedAt: Date
}

export interface UseServerActionErrorResult<TInput, TOutput, TError extends Error> extends UseServerActionResultBase<TInput, TOutput, TError> {
export interface UseServerActionErrorResult<TInput, TOutput, TError> extends UseServerActionResultBase<TInput, TOutput, TError> {
input: TInput
data: undefined
error: TError
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/adapters/fetch/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Interceptor, MaybeOptionalOptions, ThrowableError } from '@orpc/shared'
import type { Context } from '../../context'
import type { StandardHandleOptions, StandardHandler, StandardHandlerPlugin } from '../standard'
import type { FriendlyStandardHandleOptions } from '../standard/utils'
import { intercept, type Interceptor, type MaybeOptionalOptions, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { intercept, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { toFetchResponse, type ToFetchResponseOptions, toStandardLazyRequest } from '@orpc/standard-server-fetch'
import { resolveFriendlyStandardHandleOptions } from '../standard/utils'

Expand All @@ -17,7 +18,7 @@ export interface FetchHandlerInterceptorOptions<T extends Context> extends Stand
}

export interface FetchHandlerOptions<T extends Context> extends ToFetchResponseOptions {
adapterInterceptors?: Interceptor<FetchHandlerInterceptorOptions<T>, FetchHandleResult, unknown >[]
adapterInterceptors?: Interceptor<FetchHandlerInterceptorOptions<T>, FetchHandleResult, ThrowableError>[]

plugins?: FetchHandlerPlugin<T>[]
}
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/adapters/node/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Interceptor, MaybeOptionalOptions, ThrowableError } from '@orpc/shared'
import type { NodeHttpRequest, NodeHttpResponse, SendStandardResponseOptions } from '@orpc/standard-server-node'
import type { Context } from '../../context'
import type { StandardHandleOptions, StandardHandler, StandardHandlerPlugin } from '../standard'
import { intercept, type Interceptor, type MaybeOptionalOptions, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { intercept, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-node'
import { type FriendlyStandardHandleOptions, resolveFriendlyStandardHandleOptions } from '../standard/utils'

Expand All @@ -18,7 +19,7 @@ export interface NodeHttpHandlerInterceptorOptions<T extends Context> extends St
}

export interface NodeHttpHandlerOptions<T extends Context> extends SendStandardResponseOptions {
adapterInterceptors?: Interceptor<NodeHttpHandlerInterceptorOptions<T>, NodeHttpHandleResult, unknown >[]
adapterInterceptors?: Interceptor<NodeHttpHandlerInterceptorOptions<T>, NodeHttpHandleResult, ThrowableError>[]

plugins?: NodeHttpHandlerPlugin<T>[]
}
Expand Down
Loading