Support positional resolver arguments
GraphQL JS expects GraphQL field arguments to be passed to the resolver as a single args object where each argument is a named property. This makes sense in a world where your executor does not know anything about the implementation of your resolvers, but in Grats I think we can offer a more ergonomic alternative: positional arguments.
So, instead of:
/** @gqlField */
export function greeting(_: Query, args: {name: string, greeting: string}): string {
return `${args.greeting}, ${args.name}`;
}
We could support the simpler:
/** @gqlField */
export function greeting(_: Query, name: string, greeting: string): string {
return `${greeting}, ${name}`;
}
This becomes especially nice when you want to have descriptions or @deprecated on your args:
/** @gqlField */
export function greeting(
_: Query,
/** Name by which to greet the person */
name: string,
/** Salutation to use when greeting */
greeting: string,
/** @deprecated Prefer the `name` arg */
userName: string
): string {
return `${greeting}, ${name}`;
}
Implementation
At extraction time Grats could annotate the fields with directives defining which field name maps to which position. That mapping could then be implemented using the same type of resolver wrapper we use for @methodName.
Context
One reason not to use positional arguments is that it interferes with your non-GraphQL arguments like context and the extra metadata argument. However, if Grats knew which type was your context type (for example via a @gqlContext tag), it could just know which position you were expecting your context in, and pass it in this position using a similar mapping strategy:
/** @gqlContext */
type RequestContext = { username: string };
/** @gqlField */
export function greeting(
_: Query,
greeting: string,
ctx: GqlContext
): string {
return `${greeting}, ${ctx.username}`;
}
v.s the more verbose
/** @gqlField */
export function greeting(
_: Query,
args: {
/** Name by which to greet the person */
name: string,
/** Salutation to use when greeting */
greeting: string,
/** @deprecated Prefer the `name` arg */
userName: string
}
): string {
return `${args.greeting}, ${args.name}`;
}
Now even the context object does not need to be defined at a fixed postion. Maybe you prefer getting your context first?
/** @gqlContext */
type RequestContext = { username: string };
/** @gqlField */
export function greeting(
_: Query,
ctx: GqlContext,
greeting: string
): string {
return `${greeting}, ${ctx.username}`;
}
(On the other hand, maybe having a strong convention around context going after all GraphQL arguments is better for clarity in the code base. In which case that last option might be a bridge too far.)
After some discussion in other issues (https://github.com/captbaritone/grats/issues/21#issuecomment-1790899490) and on Discord, I think this is probably not great to use positional arguments. GraphQL arguments are named/keyed so, while the object syntax is awkward in JS, I think it's best to stick with it since it more semantically matches GraphQL. At some point in the future we could come back to this if we think the ergonomic value outweighs modeling GraphQL semantics accurately.
Another point in favor of this approach is the ergonomics of default values, especially when combined with descriptions.
/** @gqlField */
export async function searchItems(
_: Query,
{
query,
first = 100,
}: {
query: string;
/** Must be between 100 and 10,000 */
first?: Int;
}
): Promise<SearchItemsConnection> {
// ...
}
Is much more painful than:
/** @gqlField */
export async function searchItems(
_: Query,
query: string,
/** Must be between 100 and 10,000 */
first?: Int = 10
): Promise<SearchItemsConnection> {
// ...
}
Another argument in favor of this approach came up in Discord and I figured I'd document it here:
One appealing pattern which is uniquely enabled by implementation first solutions like Grats is that you can reuse your model types as the skeleton of your GraphQL Schema.
For example, if you had a user model:
class User {
name: string;
greet(salutation: string): string;
}
And you wanted to expose it in the graph:
/** @gqlType */
class User {
/** @gqlField */
name: string;
/** @gqlField */
greet(salutation: string): string {
return `${salutation} ${this.name}`
};
}
Today you'd either need to add a new field for greet that accepted its args as an object, or change the signature of the existing greet field to be less ergonomic for internal use. However, if we supported positional arguments, you could expose it as written without needing to duplicate it or modify it.
As I think more about this, one of the key value Grats unlocks, is to expose existing methods as fields. I think enabling that type of developer experience might bring this feature back into the set of things we should consider doing.
I took a stab at migrating a TypeGraphQL app to Grats this evening, and found that not having support for positional args made it rather difficult. Ideally, one could just:
- Add Grats annotations to existing code to match the TypeGraphQL decorators
- Validate that the Grats schema and TypeGraphQL schema match
- Delete all the TypeGraphQL directives
Right now that's not possible due to Grats requiring object shaped args.
Completed in https://github.com/captbaritone/grats/pull/149