Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/apollo-server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ function resolveType(obj: any): string {
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const iPersonClassMap = {
User: UserClass
};
4 changes: 4 additions & 0 deletions examples/express-graphql-http/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ function resolveType(obj: any): string {
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const iPersonClassMap = {
User: UserClass
};
4 changes: 4 additions & 0 deletions examples/next-js/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,7 @@ function resolveType(obj: any): string {
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const iPersonClassMap = {
User: UserClass
};
21 changes: 6 additions & 15 deletions examples/production-app/graphql/Node.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fromGlobalId, toGlobalId } from "graphql-relay";
import { ID } from "grats";
import { VC } from "../ViewerContext.js";
import { nodeClassMap, getTypeName } from "../schema.js";

/**
* Converts a globally unique ID into a local ID asserting
Expand All @@ -18,7 +19,6 @@ export function getLocalTypeAssert(id: ID, typename: string): string {
* Indicates a stable refetchable object in the system.
* @gqlInterface Node */
export interface GraphQLNode {
__typename: string;
localID(): string;
}

Expand All @@ -31,7 +31,7 @@ export interface GraphQLNode {
* @gqlField
* @killsParentOnException */
export function id(node: GraphQLNode): ID {
return toGlobalId(node.__typename, node.localID());
return toGlobalId(getTypeName(node), node.localID());
}

/**
Expand All @@ -42,20 +42,11 @@ export async function node(
vc: VC,
): Promise<GraphQLNode | null> {
const { type, id } = fromGlobalId(args.id);

// Note: Every type which implements `Node` must be represented here, and
// there's not currently any static way to enforce that. This is a potential
// source of bugs.
switch (type) {
case "User":
return vc.getUserById(id);
case "Post":
return vc.getPostById(id);
case "Like":
return vc.getLikeById(id);
default:
throw new Error(`Unknown typename: ${type}`);
const cls = nodeClassMap[type as keyof typeof nodeClassMap];
if (cls == null) {
throw new Error(`Type "${type}" does not implement Node`);
}
return cls.fetchById(vc, id);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion examples/production-app/models/Like.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { Post } from "./Post.js";
* A reaction from a user indicating that they like a post.
* @gqlType */
export class Like extends Model<DB.LikeRow> implements GraphQLNode {
__typename = "Like" as const;
static async fetchById(vc: VC, id: string): Promise<Like> {
return vc.getLikeById(id);
}

/**
* The date and time at which the post was liked.
Expand Down
4 changes: 3 additions & 1 deletion examples/production-app/models/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { connectionFromSelectOrCount } from "../graphql/gqlUtils.js";
* A blog post.
* @gqlType */
export class Post extends Model<DB.PostRow> implements GraphQLNode {
__typename = "Post" as const;
static async fetchById(vc: VC, id: string): Promise<Post> {
return vc.getPostById(id);
}

/**
* The editor-approved title of the post.
Expand Down
4 changes: 3 additions & 1 deletion examples/production-app/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { connectionFromSelectOrCount } from "../graphql/gqlUtils.js";

/** @gqlType */
export class User extends Model<DB.UserRow> implements GraphQLNode {
__typename = "User" as const;
static async fetchById(vc: VC, id: string): Promise<User> {
return vc.getUserById(id);
}

/**
* User's name. **Note:** This field is not guaranteed to be unique.
Expand Down
33 changes: 29 additions & 4 deletions examples/production-app/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import type { GqlScalar } from "grats";
import type { GqlDate as DateInternal } from "./graphql/CustomScalars.js";
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLList, GraphQLString, GraphQLScalarType, GraphQLID, GraphQLInterfaceType, GraphQLBoolean, GraphQLInputObjectType } from "graphql";
import { id as likeIdResolver, id as userIdResolver, id as postIdResolver, node as queryNodeResolver, nodes as queryNodesResolver } from "./graphql/Node.js";
import { Like as LikeClass, createLike as mutationCreateLikeResolver } from "./models/Like.js";
import { Post as PostClass, createPost as mutationCreatePostResolver } from "./models/Post.js";
import { User as UserClass, createUser as mutationCreateUserResolver } from "./models/User.js";
import { nodes as postConnectionNodesResolver, posts as queryPostsResolver } from "./models/PostConnection.js";
import { nodes as likeConnectionNodesResolver, likes as queryLikesResolver, postLikes as subscriptionPostLikesResolver } from "./models/LikeConnection.js";
import { getVc } from "./ViewerContext.js";
import { nodes as userConnectionNodesResolver, users as queryUsersResolver } from "./models/UserConnection.js";
import { Viewer as queryViewerResolver } from "./models/Viewer.js";
import { createLike as mutationCreateLikeResolver } from "./models/Like.js";
import { createPost as mutationCreatePostResolver } from "./models/Post.js";
import { createUser as mutationCreateUserResolver } from "./models/User.js";
export type SchemaConfig = {
scalars: {
Date: GqlScalar<DateInternal>;
Expand All @@ -38,7 +38,8 @@ export function getSchema(config: SchemaConfig): GraphQLSchema {
type: new GraphQLNonNull(GraphQLID)
}
};
}
},
resolveType
});
const PostType: GraphQLObjectType = new GraphQLObjectType({
name: "Post",
Expand Down Expand Up @@ -687,3 +688,27 @@ export function getSchema(config: SchemaConfig): GraphQLSchema {
types: [DateType, NodeType, CreateLikeInputType, CreatePostInputType, CreateUserInputType, MarkdownNodeType, PostContentInputType, CreateLikePayloadType, CreatePostPayloadType, CreateUserPayloadType, LikeType, LikeConnectionType, LikeEdgeType, MutationType, PageInfoType, PostType, PostConnectionType, PostEdgeType, QueryType, SubscriptionType, UserType, UserConnectionType, UserEdgeType, ViewerType]
});
}
const typeNameMap = new Map();
typeNameMap.set(LikeClass, "Like");
typeNameMap.set(PostClass, "Post");
typeNameMap.set(UserClass, "User");
function resolveType(obj: any): string {
if (typeof obj.__typename === "string") {
return obj.__typename;
}
let prototype = Object.getPrototypeOf(obj);
while (prototype) {
const name = typeNameMap.get(prototype.constructor);
if (name != null) {
return name;
}
prototype = Object.getPrototypeOf(prototype);
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const nodeClassMap = {
Like: LikeClass,
Post: PostClass,
User: UserClass
};
4 changes: 4 additions & 0 deletions examples/strict-semantic-nullability/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,7 @@ function resolveType(obj: any): string {
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const iPersonClassMap = {
User: UserClass
};
4 changes: 4 additions & 0 deletions examples/yoga/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,7 @@ function resolveType(obj: any): string {
}
throw new Error("Cannot find type name.");
}
export const getTypeName = resolveType;
export const iPersonClassMap = {
User: UserClass
};
98 changes: 98 additions & 0 deletions llm-docs/guides/node-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Node Interface (Global Object Identification)

The [Global Object Identification](https://graphql.org/learn/global-object-identification/) spec defines a pattern for fetching any object by a globally unique ID. It is used by clients like [Relay](https://relay.dev) to efficiently refetch individual objects and normalize cached data.

The spec requires:

- A `Node` interface with a single `id: ID!` field
- A root `node(id: ID!): Node` query field that can fetch any `Node` by its global ID
- A root `nodes(ids: [ID!]!): [Node]!` query field for batch fetching

Grats provides several features that make implementing this spec straightforward, with full static type safety.

> **INFO:**
> For a full working example of the Node interface in action, see our [Production App](../examples/production-app.md) example app.

## Implementation

### Step 1: Define the Node interface

Define a TypeScript interface for `Node`. Since TypeScript has a built-in `Node` type (for DOM nodes), use a different TypeScript name and rename it with `@gqlInterface Node`:

```ts
import { ID } from "grats";
import { getTypeName } from "./schema";
import { toGlobalId } from "graphql-relay";

/** @gqlInterface Node */
export interface GraphQLNode {
localID(): string;
}

/**
* @gqlField
* @killsParentOnException */
export function id(node: GraphQLNode): ID {
return toGlobalId(getTypeName(node), node.localID());
}
```

`localID()` is a TypeScript-only contract (not a GraphQL field) that each implementor must provide. It returns the type's local (unprefixed) identifier.

The functional `@gqlField` automatically adds the `id` field to every type that implements `Node`. `getTypeName` is exported by Grats' generated `schema.ts` — it returns the GraphQL typename for any class instance, so you don't need to define `__typename` on your classes. `toGlobalId` from `graphql-relay` encodes `typename:localId` as a base64 string.

### Step 2: Implement Node on your types

Each type that should be a `Node` implements the interface and provides a static `fetchById` method:

```ts
/** @gqlType */
export class User implements GraphQLNode {
constructor(private _id: string) {}

localID() {
return this._id;
}

static async fetchById(id: string): Promise<User | null> {
return db.users.get(id);
}
}
```

### Step 3: Implement the `node` and `nodes` query fields

Grats generates a `nodeClassMap` in `schema.ts` that maps every `Node` implementor's typename to its class. Use it to dispatch to the correct `fetchById`:

```ts
import { fromGlobalId } from "graphql-relay";
import { nodeClassMap } from "./schema";

/** @gqlQueryField */
export async function node(args: { id: ID }): Promise<GraphQLNode | null> {
const { type, id } = fromGlobalId(args.id);
const cls = nodeClassMap[type as keyof typeof nodeClassMap];
if (cls == null) {
throw new Error(`Type "${type}" does not implement Node`);
}
return cls.fetchById(id);
}

/** @gqlQueryField */
export async function nodes(ids: ID[]): Promise<Array<GraphQLNode | null>> {
return Promise.all(ids.map((id) => node({ id })));
}
```

## Static type safety

If you add a new type that implements `GraphQLNode` but forget to add a `fetchById` static method, TypeScript will report an error at the `cls.fetchById(...)` call — because the union of all classes in `nodeClassMap` now includes a class without that method.

This eliminates the common bug of adding a `Node` implementor but forgetting to register it in the `node()` resolver.

## How it works

Grats generates two exports in `schema.ts` that power this pattern:

- **`nodeClassMap`** — An object mapping GraphQL typenames to their class constructors for every type that implements the `Node` interface. Grats generates one of these maps per interface in your schema.
- **`getTypeName`** — A function that returns the GraphQL typename for any class instance, using the same prototype-chain resolution that GraphQL uses internally. This lets you encode global IDs without defining `__typename` on your classes.
24 changes: 11 additions & 13 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,19 +1351,17 @@ class Extractor {

let exported: { tsModulePath: string; exportName: string | null } | null =
null;
if (!hasTypeName) {
const isExported = node.modifiers?.find(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
const isDefault = node.modifiers?.find(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
);
if (isExported) {
exported = {
tsModulePath: relativePath(node.getSourceFile().fileName),
exportName: isDefault ? null : node.name.text,
};
}
const isExported = node.modifiers?.find(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
const isDefault = node.modifiers?.find(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
);
if (isExported) {
exported = {
tsModulePath: relativePath(node.getSourceFile().fileName),
exportName: isDefault ? null : node.name.text,
};
}

const directives = this.collectDirectives(node);
Expand Down
Loading
Loading