Effect notes: tRPC
Today I've been implementing a new feature, and in keeping with the general trends, I've been trying to implement new code in Effect. This is mostly low-drama, but I needed to start connecting Effect to tRPC.
A theme is that Effect's documentation is pretty weak on details about integrations, partly because Effect has its own systems that you could use instead. In this case, @effect/rpc, but that module is still on a 0.x release and has no documentation at all on the Effect website, so for now, people are going to use tRPC instead.
Platform layers like tRPC and React Router are a big part of why we adopted Result types in the first place. We've been using neverthrow for a long time and are now switching to Effect. The common ingredient that matters is that with Result types, you get a type like Result<ErrorType, SuccessType>, so you know what errors a function could produce. In TypeScript with thrown exceptions, you don't know what kinds of errors can happen. Then, ideally, we're able to map those errors into platform-specific errors: tRPC wants instances of TRPCError, React Router wants Response objects, Fastify wants its own error type, etc.
So previously we had this code to interoperate between neverthrow and tRPC:
export function mapErrorToTRPC(error: MappableErrors) {
if (error instanceof UnexpectedError) {
Sentry.captureException(error);
}
if (error instanceof RedirectError) {
// This should never happen, tRPC does not support redirects.
return new TRPCError({ code: "INTERNAL_SERVER_ERROR", cause: error });
}
if (error instanceof MethodNotAllowedError) {
// TRPC uses an unstandard phrase for this error
return new TRPCError({ code: "METHOD_NOT_SUPPORTED", cause: error });
}
// console.error(error);
return new TRPCError({ code: error.code, cause: error });
}
/**
* Unwrap a result, throwing a tRPC-specific error
*/
export function unwrapForTrpc<T>(
result: Result<T, MappableErrors> | ResultAsync<T, MappableErrors>
) {
return result.match(
(t) => t,
(error) => {
throw mapErrorToTRPC(error);
}
);
}So: we take a result, if there's an error, throw it, if it's successful, just return the value. This makes tRPC happy. This function is then injected with tRPC middleware so that query and mutation files get this function as a part of the context object instead of having to import the function directly.
So today was solving that puzzle for Effect, which looks like this:
export async function effectTrpc<A, E>(effect: Effect.Effect<A, E>) {
const exit = await Effect.runPromiseExit(effect);
return Exit.match(exit, {
onFailure(exit) {
return Option.match(Cause.failureOption(exit), {
onNone() {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unexpected error type",
});
},
onSome(f) {
throw mapErrorToTRPC(f as MappableErrors);
},
});
},
onSuccess(v) {
return v;
},
});
}It's a bit more intensive than the neverthrow example because there's the matter of how Exit works in Effect - when an Effect fails here, we get an Exit which is a Failure, which has a Cause inside of it which we want. Some failures don't have causes, so we have to handle that case too.
Is it beautiful? No. But I suspect that:
- Most applications are not beautiful greenfield environments and have this kind of adapter code.
- The adapter code is always a little gross.
The main alternative path here is some kind of higher level function that would let us define tRPC routes with Effects directly. This would be nifty but I fear that level of abstraction - one can definitely get too fancy with this kind of thing, and we're trying to be mostly incremental.
Issues
- I haven't filed any new issues but unfortunately most of the issues I have filed are stalled.
There's a PR for reverse cron iteration, but it hasn't been merged yet.That PR got merged! - Following along with Effect development, it's nice that there's a lot of effort on Effect 4, but I hope that it doesn't cause a lot of churn in the ecosystem. And I hope it's the last big rewrite for a long time.