Replies: 4 comments 5 replies
|
I would recommend keeping Ideally, you should only be able to return the successful output value or explicitly defined error values, which is especially nice for documentation of what's returned with each error status code. throw/an opaque ORPCError should be reserved for unexpected errors/non-strict error types on projects that don't mind having them (as thrown types can't be checked/verified by TypeScript anyway) |
|
How does this interact with the effect integration? One lesson from effect is that throw and promise.reject make understanding what a function actually does really difficult. For example, if I call a function within my procedure, and the pattern is to throw new OrpcError, I don't know that function throws it because it's not part of its type definition. Errors as values is becoming more and more prevalent in the TS community. |
|
I agree with removing the special treatment of returned I prototyped two primitives for adapter authors:
The library defines its own representation. oRPC owns the typed boundary. There is no shared Result format and no requirement to use the same library on both sides. Server: turn the library's error channel into an oRPC failureThe server conversion receives ordinary handler
For Better Result, Complete generic server wrapper, including the captured handlerimport type { AdaptedHandler, AnyORPCError, Context, ORPCErrorConstructorMap, ProcedureHandlerOptions } from '@orpc/server'
import type { Promisable } from '@orpc/shared'
import type { InferErr, InferOk, Result } from 'better-result'
import { createHandlerAdapter } from '@orpc/server'
export function betterResultHandler<TContext extends Context, TInput, TErrorConstructorMap extends ORPCErrorConstructorMap<any>, TResult extends Result<unknown, AnyORPCError>>(
handler: (options: ProcedureHandlerOptions<TContext, TInput, TErrorConstructorMap>, input: TInput) => Promisable<TResult>,
): AdaptedHandler<TContext, TInput, InferOk<TResult>, InferErr<TResult>, TErrorConstructorMap> {
return createHandlerAdapter(async ({ options, fail }) => {
// Infer the whole result union before separating success and error branches.
const result = await handler(options, options.input)
return result.isOk() ? result.value as InferOk<TResult> : fail(result.error as InferErr<TResult>)
})
}The application supplies Here is a deterministic business function with both a success and a failure: import { ORPCError } from '@orpc/server'
import { Result } from 'better-result'
export function half(n: number) {
return n % 2 === 0
? Result.ok(n / 2)
: Result.err(new ORPCError('ODD_NUMBER', { data: { n } }))
}Then connect it to oRPC: import { betterResultHandler } from '@orpc/experimental-better-result'
import { os } from '@orpc/server'
import { z } from 'zod'
import { half } from './half'
export const router = {
half: os.input(z.number().int())
.handler(betterResultHandler(({ input }) => half(input))),
}Zod checks the integer input; Client: turn an ordinary RPC call into the chosen representationThe client primitive works in the other direction. The generic Complete generic client adapterimport type { AdaptedClient, AnyNestedClient, AnyORPCError, ClientAdapterKind } from '@orpc/client'
import { createClientAdapter, safe } from '@orpc/client'
import { Result } from 'better-result'
interface ResultKind extends ClientAdapterKind {
readonly result: Promise<Result<this['output'], Extract<this['error'], AnyORPCError>>>
}
export type ResultClient<T extends AnyNestedClient> = AdaptedClient<T, ResultKind>
export const createResultClient = createClientAdapter<ResultKind>(async ({ call }) => {
const result = await safe(call())
if (result.isSuccess) {
return Result.ok(result.data)
}
if (result.inferableError) {
return Result.err(result.inferableError)
}
throw result.error
})Apply that wrapping function to the native client and call both paths: import { createResultClient } from '@orpc/experimental-better-result/client'
import { createRouterClient } from '@orpc/server'
import { router } from './router'
const rpc = createRouterClient(router)
const client = createResultClient(rpc)
const good = await client.half(4) // Ok(2)
const bad = await client.half(3) // Err: ODD_NUMBER
if (bad.isErr()) {
console.log(bad.error.code) // 'ODD_NUMBER'
console.log(bad.error.data.n) // 3; typed as number
}This uses a direct client to keep the example small. The same adapter can wrap a remote oRPC client. Better Result objects do not cross the wire: the server emits ordinary RPC data/errors, and the client reconstructs its representation. A transport failure does not automatically become a typed business error. Neither side chooses the other's libraryAn Effect client can call this Better Result handler. A Better Result client can call an Effect handler. A router can mix native, Effect, Better Result and DIY handlers while each client keeps its chosen representation. Adaptation can also be applied to an individual procedure. Every client can call every handler; the rows do not imply matching pairs. Three ways to establish the error types
With declarations, These guarantees concern the adapter's explicit error channel. Native throws do not become inferred checked exceptions, and unexpected failures remain possible. Internally, I think this is the best of both worlds. This way we keep oRPC native js/ts error handling at core, without any notion of built-in result pattern. But we give easy extension system to make anyone map any result system to oRPC internal representation, with total decoupling between client/server. |
|
Thanks everyone for your votes and suggestions! With an 85/15 split, |
Uh oh!
There was an error while loading. Please reload this page.
In v2, a handler can
return new ORPCError(...)and oRPC treats it as a typesafe error. The client can then infer its shape:I am thinking about removing this and keeping only
throw. Here is why.1.
returnfor an error is not the JS/TS wayIn JavaScript,
returnmeans success andthrowmeans failure. oRPC v1 followed this rule: you alwaysthrowanORPCError. Many users like that, and it matches the rest of the ecosystem. Libraries like Effect have their own error channel, but oRPC was never designed that way.v2 added a second way. Now
return new ORPCError(...)andthrow new ORPCError(...)mean the same thing. You have to remember this rule when reading code, and areturnthat is actually an error is easy to misread.2. Types and runtime can disagree
TypeScript can only detect a returned
ORPCErrorwhen the return type is precise. If the handler returnsunknown,any,{}, or a wide object type, TypeScript sees a normal output. But at runtime oRPC still checksinstanceof ORPCError, throws the value, and marks it as inferable.With
throwonly, the rule is simple: what youreturnis the output, what youthrowis an error. No hidden check in between.3. Every handler pays for it
Runtime: every handler result goes through an
instanceof ORPCErrorcheck. When it matches, the error is cloned and re-thrown with new flags. This runs on every call, even if you never use the feature.Type-level: every
.handlermust split its return type into "output" and "error" usingExclude<T, AnyORPCError>andExtract<T, AnyORPCError>. EveryProcedurealso carries an extraTReturnedErrorgeneric that spreads intocall,createProcedureClient, links, and integrations.Removing this makes type checking faster and the types easier to read.
4. Cleaner API
throw.ORPCErrorfrom a handler.ORPCErrornever affects client types (it is just thrown under the hood), but the handler return type is stillOutput | AnyORPCErrortoday. After this change it would be justOutput..errorsand theerror()factory stay as the typesafe way to define errors:Please vote below and share your thoughts, especially if you rely on returning
ORPCErrortoday.59 votes ·
All reactions