Generated Code Reference
EXPERIMENTAL. The exact shape of generated code may change without notice. See the README.
Reference documentation for the TypeScript code generated by protoc-gen-dataloader. Every snippet on this page is excerpted verbatim from the plugin’s golden test fixtures, so it always reflects real output:
- Entity mode:
tests/golden-dataloader/testapis.dataloader.entity/__expected__/testapis/dataloader/entity/entity.pb.dataloader.ts, generated fromdevPackages/testapis-proto/proto/testapis/dataloader/entity/entity.proto - Group mode:
tests/golden-dataloader/testapis.dataloader.group/__expected__/testapis/dataloader/group/group.pb.dataloader.ts, generated fromdevPackages/testapis-proto/proto/testapis/dataloader/group/group.proto
File Layout
One generated file per .proto file that has at least one RPC declaring (graphql.rpc).batch: <proto path>.pb.dataloader.ts (suffix configurable via filename_suffix). Every annotated RPC becomes one exported loader accessor named <rpcNameCamelCase>Loader.
Imports
Every generated file imports the runtime helpers — including the RpcLoader wrapper type every loader const is annotated with (see Loader Params below) — create/MessageShape (and MessageInitShape when any loader has params) from @bufbuild/protobuf, and the protobuf-es service and schema symbols the loaders reference. Generated files never import from dataloader directly; the RpcLoader wrapper type from @proto-graphql/connect-runtime is the only DataLoader-adjacent type they reference:
// @generated by protoc-gen-dataloader vX.Y.Z with parameter "import_prefix=@proto-graphql/e2e-testapis-protobuf-es-v2/lib/"
// @generated from file testapis/dataloader/entity/entity.proto (package testapis.dataloader.entity, syntax proto3)
/* eslint-disable */
import type {
ProtoGraphqlConnectContext,
RpcLoader,
} from "@proto-graphql/connect-runtime";
import { createRpcLoader } from "@proto-graphql/connect-runtime";
import type { MessageInitShape, MessageShape } from "@bufbuild/protobuf";
import { create } from "@bufbuild/protobuf";
import {
BatchGetOrdersRequestSchema,
BatchGetUsersInTenantRequestSchema,
BatchGetUsersRequestSchema,
BatchGetUsersWithLocaleRequestSchema,
OrderSchema,
UserSchema,
UserService,
} from "@proto-graphql/e2e-testapis-protobuf-es-v2/lib/testapis/dataloader/entity/entity_pb";Entity/request/response types are referenced through protobuf-es v2’s MessageShape<typeof XSchema> rather than a class — consistent with how protoc-gen-pothos’s protobuf-es runtime reference works.
Entity Mode
Declared with batch and no group: true. From the source proto:
service UserService {
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse) {
option (graphql.rpc).batch = { entity_key: "id" };
}
}key_field (ids) and entity_field (users) are inferred. entity_key must be explicit for now — inferring it from the entity’s federation @key is a fallback planned once federation support lands upstream (see (graphql.rpc).batch annotation reference). Generated code:
export const batchGetUsersLoader: (
ctx: ProtoGraphqlConnectContext,
) => RpcLoader<string, MessageShape<typeof UserSchema> | null> =
createRpcLoader({
service: UserService,
method: "batchGetUsers",
requestSchema: BatchGetUsersRequestSchema,
call: (client, keys, params, opts) =>
client.batchGetUsers(
create(BatchGetUsersRequestSchema, { ...params, ids: [...keys] }),
opts,
),
extractEntities: (res) => res.users,
extractKey: (user: MessageShape<typeof UserSchema>) => user.id,
});The accessor ((ctx) => RpcLoader<...>) takes only ctx — it’s memoized per ctx, so calling it repeatedly for the same ctx is cheap and returns the same wrapper. A missing key resolves to null: await batchGetUsersLoader(ctx).load("unknown-id") → null, never a rejection.
bigint Keys and max_batch_size
max_batch_size caps how many keys go into one RPC call — DataLoader splits a larger request into multiple calls automatically:
rpc BatchGetOrders(BatchGetOrdersRequest) returns (BatchGetOrdersResponse) {
option (graphql.rpc).batch = { entity_key: "id", max_batch_size: 2 };
}export const batchGetOrdersLoader: (
ctx: ProtoGraphqlConnectContext,
) => RpcLoader<bigint, MessageShape<typeof OrderSchema> | null> =
createRpcLoader({
service: UserService,
method: "batchGetOrders",
requestSchema: BatchGetOrdersRequestSchema,
call: (client, keys, params, opts) =>
client.batchGetOrders(
create(BatchGetOrdersRequestSchema, { ...params, ids: [...keys] }),
opts,
),
extractEntities: (res) => res.orders,
extractKey: (order: MessageShape<typeof OrderSchema>) => order.id,
maxBatchSize: 2,
});Order.id is int64, so the key type is bigint (see Key Type Mapping). Requesting 3 keys results in 2 RPC calls (e.g. 2 + 1) instead of one.
Group Mode
Declared with batch: { group: true }. Since the grouping key (user_id) is not the entity’s own @key, entity_key is always required in group mode — there is nothing to fall back to:
service ReviewService {
rpc BatchListReviewsByUsers(BatchListReviewsByUsersRequest) returns (BatchListReviewsByUsersResponse) {
option (graphql.rpc).batch = { group: true, entity_key: "user_id" };
}
}export const batchListReviewsByUsersLoader: (
ctx: ProtoGraphqlConnectContext,
) => RpcLoader<string, MessageShape<typeof ReviewSchema>[]> = createRpcLoader({
service: ReviewService,
method: "batchListReviewsByUsers",
requestSchema: BatchListReviewsByUsersRequestSchema,
call: (client, keys, params, opts) =>
client.batchListReviewsByUsers(
create(BatchListReviewsByUsersRequestSchema, {
...params,
userIds: [...keys],
}),
opts,
),
extractEntities: (res) => res.reviews,
extractKey: (review: MessageShape<typeof ReviewSchema>) => review.userId,
group: true,
});The value type is MessageShape<typeof ReviewSchema>[], not ... | null, and a missing key resolves to []: await batchListReviewsByUsersLoader(ctx).load("user-with-no-reviews") → [].
Params Variants
When the request has fields besides key_field, the plugin generates a <RpcName>LoaderParams type (the request’s full MessageInitShape, including the key field — see the note below) and folds it into the RpcLoader wrapper’s third type parameter (PArgs), which load/loadMany/loader accept as a trailing argument at call time — not accessor time. The accessor itself ((ctx) => RpcLoader<...>) always takes only ctx, regardless of whether the RPC has params. Whether PArgs is [params?: X] (optional) or [params: X] (required) mirrors whether any of those fields is a required field, using the same isRequiredField semantics as Input type generation (implicit-presence scalars are required; optional / NULLABLE nullability makes a field non-required).
Optional Params
message BatchGetUsersWithLocaleRequest {
repeated string ids = 1;
// `optional` => proto3Optional => not required: loader `params` stays optional.
optional string locale = 2;
}export type BatchGetUsersWithLocaleLoaderParams = MessageInitShape<
typeof BatchGetUsersWithLocaleRequestSchema
>;
export const batchGetUsersWithLocaleLoader: (
ctx: ProtoGraphqlConnectContext,
) => RpcLoader<
string,
MessageShape<typeof UserSchema> | null,
[params?: BatchGetUsersWithLocaleLoaderParams]
> = createRpcLoader({
service: UserService,
method: "batchGetUsersWithLocale",
requestSchema: BatchGetUsersWithLocaleRequestSchema,
call: (client, keys, params, opts) =>
client.batchGetUsersWithLocale(
create(BatchGetUsersWithLocaleRequestSchema, {
...params,
ids: [...keys],
}),
opts,
),
extractEntities: (res) => res.users,
extractKey: (user: MessageShape<typeof UserSchema>) => user.id,
});// usage: params is the (optional) trailing argument to load/loadMany, not to the accessor
await batchGetUsersWithLocaleLoader(ctx).load("user-1");
await batchGetUsersWithLocaleLoader(ctx).load("user-1", { locale: "ja" });Required Params
message BatchGetUsersInTenantRequest {
repeated string ids = 1;
// Implicit presence scalar => required: loader `params` becomes mandatory.
string tenant_id = 2;
}export type BatchGetUsersInTenantLoaderParams = MessageInitShape<
typeof BatchGetUsersInTenantRequestSchema
>;
export const batchGetUsersInTenantLoader: (
ctx: ProtoGraphqlConnectContext,
) => RpcLoader<
string,
MessageShape<typeof UserSchema> | null,
[params: BatchGetUsersInTenantLoaderParams]
> = createRpcLoader({
service: UserService,
method: "batchGetUsersInTenant",
requestSchema: BatchGetUsersInTenantRequestSchema,
call: (client, keys, params, opts) =>
client.batchGetUsersInTenant(
create(BatchGetUsersInTenantRequestSchema, { ...params, ids: [...keys] }),
opts,
),
extractEntities: (res) => res.users,
extractKey: (user: MessageShape<typeof UserSchema>) => user.id,
});// usage: params is a required trailing argument to load/loadMany
await batchGetUsersInTenantLoader(ctx).load("user-1", { tenantId: "acme" });Here PArgs is [params: BatchGetUsersInTenantLoaderParams] (no ?) — callers must pass { tenantId: "..." } to load/loadMany/loader. This is a type-level requirement only; nothing is validated at runtime (an invalid or empty request is rejected by the server, same as any other RPC call).
Note on
LoaderParams’s shape: the params type includeskey_fielditself (idsabove), notOmit<Request, "ids">. This is intentional — protobuf-es v2’sMessageInitShaperesolves to a union, andOmitdoes not distribute over unions cleanly. Passingidsthroughparamsis harmless:callalways overwrites it with the batch’s actual keys, so any value you pass there is ignored.
The loader() Escape Hatch
Besides load/loadMany, RpcLoader exposes loader(...args), which resolves (creating on first sight) the underlying per-params DataLoader instance directly — useful for .prime() / .clear() / .clearAll():
batchGetUsersWithLocaleLoader(ctx).loader({ locale: "ja" }).prime("user-1", cachedUser);loader(...) shares the same per-ctx, per-params-value cache as load/loadMany, so priming through loader() is visible to subsequent load() calls with the same params.
Key Type Mapping
The TypeScript key type K in RpcLoader<K, ...> is derived from key_field’s proto element type:
| Proto element type | TS key type |
|---|---|
string | string |
int32 / uint32 / sint32 / fixed32 / sfixed32 | number |
int64 / uint64 / sint64 / fixed64 / sfixed64 | bigint |
bool / enum / message / bytes / float / double | not supported as a key (codegen error) |
Key-Matching Semantics
Implemented once in @proto-graphql/connect-runtime’s createRpcLoader (not regenerated per RPC), so every loader — regardless of mode — behaves the same way:
- Missing key: a requested key absent from the response resolves to
null(entity mode) or[](group mode). It never rejects on its own. - Response order: entities are matched back to keys by
extractKey, not by response order — a shuffled or reordered response still matches correctly. - Duplicate keys in the response: entity mode keeps the last matching entity; group mode collects all of them into that key’s array.
- Extra entities: entities whose key was not requested are ignored (harmless, not an error).
- RPC error: if the
callclosure throws or its promise rejects, every key in that batch rejects with the same error — the plugin never converts a transport/RPC error into a per-keynull/[], and never translates it into a GraphQL error itself (that is left to the caller, e.g. a future federationresolveReferencewiring). paramssplit batches:load/loadMany/loadercalls against the same accessor’s wrapper with a differentparamsvalue (compared by encoding, not by object identity) never share a batch — each distinctparamsvalue gets its own DataLoader instance per context, created lazily the first time that value is seen.undefinedand{}are treated as equivalent. Deliberately passing differentparamsper key defeats batching (falls back to one RPC call per distinct params value), which is the correct/expected tradeoff, not a bug — this batching physics is unchanged by params moving from accessor time to load time.max_batch_size: when set, DataLoader never sends more than that many keys in onecallinvocation — a larger set of concurrently-requested keys is automatically split across multiple RPC calls.