Ok, as promised here's my solution. This doesn't handle the "all the services in an object" case but it does prevent the need for client code to import types from the server packages.
So the first thing is that yes, the types are kind of weird. The ServiceDefinition
type and its related types take both the name and parameters, but as you noted it just drops the parameters. The related version of ServiceDefinition returned by the client factory functions does hold on to the handler function info though, i'm guessing by using some infer
clauses in the client functions themselves.
It would be really convenient if the exposed types actually contained the relevant handler definitions as well, so that we can pass the types around more easily. But that said, here's what I'm doing right now.
I'm using a monorepo with a package named @repo/restate
which contains definitions of all the functions and also the factory functions for each service's client.
export interface TokenBucketLimiterFunctions {
/** Try to consume the specified amount of tokens. */
consume: (
ctx: ObjectContext,
opts: TokenBucketLimiterOptions
) => Promise<RateLimiterConsumeResult>;
/** Manually refill tokens. */
refill: (ctx: ObjectContext, tokens: number) => Promise<void>;
/** Clear all data for this rate limiter. */
clear: (ctx: ObjectContext) => Promise<void>;
/** Inspect the current state of the rate limiter. */
status: (ctx: ObjectSharedContext) => Promise<BucketCount>;
/** An internal call for checking if the rate limiter is stale and can be cleared. */
checkStale: (ctx: ObjectContext) => Promise<void>;
}
export const TokenBucketLimiterName = 'TokenBucketLimiter';
export function tokenBucketLimiterClient(
client: Ingress,
key: string
): IngressClient<TokenBucketLimiterFunctions>;
export function tokenBucketLimiterClient(
client: Context,
key: string
): Client<TokenBucketLimiterFunctions>;
export function tokenBucketLimiterClient(client: ClientFactory, key: string) {
return client.objectClient<
VirtualObjectDefinition<typeof TokenBucketLimiterName, TokenBucketLimiterFunctions>
>({ name: TokenBucketLimiterName }, key);
The multiple definitions for the client function allow me to call the function with either an Ingress client or a context, and get proper Typescript typing for either one, since the return values are slightly different.
Because of how the client functions work, you have to write it this way. If you define the VirtualObjectDefinition
with the generics as its own type separately, then it loses the handler info.
There's a similar function for creating a sendClient
So that handles the client side. Then in the server itself I use the same types to ensure that the prototypes match.
restate.object({
name: TokenBucketLimiterName,
handlers: {
// the code here
} satisfies TokenBucketLimiterFunctions
})
The main downside of this is that you have to define some of your types (data payloads, return values) in a different place from where they're actually used in the server, so there's more jumping around. The upside of course is that you don't have to import the application server packages in your client packages, and it avoids any potential for circular dependencies as well.
This separation of types also makes it much easier to create a restate service with certain mocked dependencies, because then you don't have to try to extract the type from a factory function for the object.
I do a lot of this type of thing, with some special glue code to also register multiple different objects with different service names
export function createTestService(db: Database) {
return restate.object({
// In tests, this adds a
name: endpointId(RateLimiterName),
handlers: {
...
}
});
All this is to make tests fast and run against a single existing restate instance in parallel since TestContainers are slow to start up, and also have a postgres database per test. At some point I might be able to share more about how I'm doing that too.
The endpointId
function looks like this, and then most of the code is in the attached restate_test_environment.ts
file.
const uniqueId = makeUuidv7();
export function endpointId<T extends string>(name: T): T {
if (process.env.VITEST) {
return `${name}_test_${uniqueId}` as T;
}
return name;
}