Skip to content

Instantly share code, notes, and snippets.

@dmmulroy
Last active January 13, 2026 14:30
Show Gist options
  • Select an option

  • Save dmmulroy/9b4b0a5cad92941885cdfc6e304a0d39 to your computer and use it in GitHub Desktop.

Select an option

Save dmmulroy/9b4b0a5cad92941885cdfc6e304a0d39 to your computer and use it in GitHub Desktop.
Durable Object stub wrapper with better-result hydration
// example-usage.ts
import { Result } from "better-result";
import { stubWithBetterResultHydration } from "./stub-with-better-result-hydration";
import type { MyDurableObject } from "./my-durable-object";
// In a Worker handler:
export default {
async fetch(req: Request, env: Env) {
const id = env.MY_DO.idFromName("user-123");
const rawStub = env.MY_DO.get(id);
// Wrap the stub once
const stub = stubWithBetterResultHydration<MyDurableObject>(rawStub);
// All methods now return Result with DurableObjectError in error union
const result = await stub.createUser({ name: "Alice" });
if (result.isErr()) {
// result.error is ParseError | ValidationError | DurableObjectError
return new Response(result.error.message, { status: 500 });
}
// result.value is User
return Response.json(result.value);
}
};
// Or with Result.gen:
const handleRequest = async (env: Env) => {
const stub = stubWithBetterResultHydration<MyDurableObject>(
env.MY_DO.get(env.MY_DO.idFromName("user-123"))
);
return Result.gen(async function* () {
const user = yield* Result.await(stub.createUser({ name: "Alice" }));
const profile = yield* Result.await(stub.getProfile(user.id));
return Result.ok({ user, profile });
});
};
// stub-with-better-result-hydration.ts
import { DurableObjectError } from "./errors";
import { Err, Ok, Result, type ResultType } from "better-result";
/**
* Adds DurableObjectError to Result error unions, wraps non-Result returns.
* - Promise<Result<T, E>> → Promise<Result<T, E | DurableObjectError>>
* - Promise<T> → Promise<Result<T, DurableObjectError>>
*/
type AddDOError<R> =
R extends Promise<ResultType<infer T, infer E>>
? Promise<ResultType<T, E | DurableObjectError>>
: R extends Promise<infer T>
? Promise<ResultType<T, DurableObjectError>>
: R;
/**
* Maps DO stub methods, adding DurableObjectError to all returns.
* All async methods return Result with DurableObjectError in error union.
*/
type HydratedStub<T> = {
[K in keyof T]: T[K] extends (...args: infer P) => infer R ? (...args: P) => AddDOError<R> : T[K];
};
/**
* Wraps a Durable Object stub to:
* 1. Rehydrate serialized Result returns (Ok/Err class instances)
* 2. Catch DO infrastructure errors and wrap in DurableObjectError
* 3. Wrap non-Result returns in Ok
*
* All methods return Result with DurableObjectError in error union.
*
* IMPORTANT: After any DO exception, stub may be broken.
* Subsequent requests on same stub may fail. Recreate stub after errors.
*
* @param stub - DO stub to wrap
* @returns Proxied stub with Result rehydration and error handling
*
* @example
* const stub = stubWithBetterResultHydration(namespace.get(id));
* const result = await stub.createUser(input); // Result<User, ParseError | DurableObjectError>
*/
export const stubWithBetterResultHydration = <T extends Rpc.DurableObjectBranded>(
stub: DurableObjectStub<T>,
): HydratedStub<T> => {
return new Proxy(stub, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") {
return value;
}
return async (...args: unknown[]) => {
let result: unknown;
try {
result = await value.apply(target, args);
} catch (e) {
// DO infrastructure error - wrap in DurableObjectError
return new Err(DurableObjectError.from(e, String(prop)));
}
// Rehydrate serialized Results back into Ok/Err instances
const hydrated = Result.hydrate(result);
if (hydrated) {
return hydrated;
}
// Non-Result return - wrap in Ok
return new Ok(result);
};
},
}) as unknown as HydratedStub<T>;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment