diff --git a/examples/required-typename/CHANGELOG.md b/examples/required-typename/CHANGELOG.md new file mode 100644 index 000000000..15b9df1c6 --- /dev/null +++ b/examples/required-typename/CHANGELOG.md @@ -0,0 +1,7 @@ +# @pothos-examples/required-typename + +## 3.1.19 + +### Patch Changes + +- Initial release: Example demonstrating a custom plugin that adds requiredTypename() method to interface and union refs diff --git a/examples/required-typename/README.md b/examples/required-typename/README.md new file mode 100644 index 000000000..2025e888b --- /dev/null +++ b/examples/required-typename/README.md @@ -0,0 +1,102 @@ +# Required Typename Example + +This example demonstrates a custom Pothos plugin that adds a `requiredTypename()` method to interface and union refs. + +## What it does + +The `requiredTypename()` method updates the TypeScript types of an interface or union ref to require that `__typename` is present as a required string property on any fields returning that interface or union. This helps ensure type safety when working with GraphQL's `__typename` field. + +**Important:** When `__typename` is provided in resolved values, no `resolveType` or `isTypeOf` is required for unions and interfaces, as GraphQL can use the `__typename` field directly to determine the concrete type. + +## Plugin Implementation + +The plugin is implemented in `src/required-typename-plugin.ts` and includes: + +1. **Type Declarations**: Global TypeScript declarations that add the `requiredTypename()` method to `InterfaceRef`, `ImplementableInterfaceRef`, and `UnionRef` +2. **Method Implementations**: Prototype methods attached to `InterfaceRef` and `UnionRef` classes that return the same ref with updated types + +Note: This plugin doesn't need to be registered with the builder since it doesn't use any plugin hooks - it only adds methods and types. + +## Usage + +```typescript +import SchemaBuilder from '@pothos/core'; +import './required-typename-plugin'; + +const builder = new SchemaBuilder({}); + +// Create a union with required typename +// When __typename is provided, no resolveType is required +const PetUnion = builder.unionType('Pet', { + types: [DogType, CatType], +}).requiredTypename(); + +// Create an interface with required typename - chained immediately +const NodeInterface = builder.interfaceRef<{ id: string }>('Node').implement({ + fields: (t) => ({ + id: t.exposeID('id'), + }), +}).requiredTypename(); + +// Now when you use these types, TypeScript enforces __typename +builder.queryType({ + fields: (t) => ({ + pets: t.field({ + type: [PetUnion], + resolve: () => { + // TypeScript requires __typename to be present in the returned objects + return [ + { __typename: 'Dog', name: 'Fido', breed: 'Golden Retriever' }, + { __typename: 'Cat', name: 'Whiskers', livesRemaining: 9 }, + ]; + }, + }), + }), +}); +``` + +## Running the Example + +```bash +# Install dependencies (from repo root) +pnpm install + +# Start the server +pnpm --filter @pothos-examples/required-typename start + +# Run tests +pnpm --filter @pothos-examples/required-typename test + +# Type check +pnpm --filter @pothos-examples/required-typename type +``` + +The server will start at http://localhost:4000/graphql with a GraphiQL interface. + +## Example Query + +```graphql +query ExampleQuery { + pets { + __typename + ... on Dog { + name + breed + } + ... on Cat { + name + livesRemaining + } + } + nodes { + __typename + id + ... on Person { + name + } + ... on Company { + companyName + } + } +} +``` diff --git a/examples/required-typename/package.json b/examples/required-typename/package.json new file mode 100644 index 000000000..51050f6c3 --- /dev/null +++ b/examples/required-typename/package.json @@ -0,0 +1,21 @@ +{ + "private": true, + "$schema": "https://json.schemastore.org/package.json", + "version": "3.1.19", + "name": "@pothos-examples/required-typename", + "main": "src/server.ts", + "types": "src/server.ts", + "scripts": { + "start": "node -r @swc-node/register src/server.ts", + "test": "vitest", + "type": "tsc --noEmit" + }, + "dependencies": { + "@pothos/core": "workspace:*", + "graphql": "^16.10.0", + "graphql-yoga": "5.15.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/examples/required-typename/src/builder.ts b/examples/required-typename/src/builder.ts new file mode 100644 index 000000000..a8dd93c48 --- /dev/null +++ b/examples/required-typename/src/builder.ts @@ -0,0 +1,8 @@ +import SchemaBuilder from '@pothos/core'; +import './required-typename-plugin'; + +export const builder = new SchemaBuilder<{ + Scalars: { + ID: { Input: string; Output: string }; + }; +}>({}); diff --git a/examples/required-typename/src/required-typename-plugin.ts b/examples/required-typename/src/required-typename-plugin.ts new file mode 100644 index 000000000..a54a7e07a --- /dev/null +++ b/examples/required-typename/src/required-typename-plugin.ts @@ -0,0 +1,62 @@ +import { ImplementableInterfaceRef, InterfaceRef, type SchemaTypes, UnionRef } from '@pothos/core'; + +// Add requiredTypename method to InterfaceRef +InterfaceRef.prototype.requiredTypename = function requiredTypename< + Types extends SchemaTypes, + T, + P, +>(this: InterfaceRef) { + // Return the same ref but with updated types to require __typename + return this as unknown as InterfaceRef; +}; + +// Add requiredTypename method to ImplementableInterfaceRef +// This extends InterfaceRef but needs its own prototype method implementation +ImplementableInterfaceRef.prototype.requiredTypename = function requiredTypename< + Types extends SchemaTypes, + T, + P, +>(this: ImplementableInterfaceRef) { + // Return the same ref but with updated types to require __typename + return this as unknown as ImplementableInterfaceRef; +}; + +// Add requiredTypename method to UnionRef +UnionRef.prototype.requiredTypename = function requiredTypename( + this: UnionRef, +) { + // Return the same ref but with updated types to require __typename + return this as unknown as UnionRef; +}; + +// Global type declarations to make the methods available in TypeScript +declare global { + export namespace PothosSchemaTypes { + export interface InterfaceRef { + /** + * Returns the same interface ref but with __typename as a required field. + * This ensures that any fields returning this interface will have __typename: string + * as a required property in their resolved type. + */ + requiredTypename(): InterfaceRef; + } + + export interface ImplementableInterfaceRef { + /** + * Returns the same interface ref but with __typename as a required field. + * This ensures that any fields returning this interface will have __typename: string + * as a required property in their resolved type. + */ + requiredTypename(): ImplementableInterfaceRef; + } + + export interface UnionRef { + /** + * Returns the same union ref but with __typename as a required field. + * This ensures that any fields returning this union will have __typename: string + * as a required property in their resolved type. + */ + requiredTypename(): UnionRef; + } + } +} diff --git a/examples/required-typename/src/schema.test.ts b/examples/required-typename/src/schema.test.ts new file mode 100644 index 000000000..45cee7227 --- /dev/null +++ b/examples/required-typename/src/schema.test.ts @@ -0,0 +1,94 @@ +import { execute, parse } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { schema } from './schema'; + +describe('requiredTypename plugin', () => { + it('should resolve pets with __typename', async () => { + const query = parse(` + query { + pets { + __typename + ... on Dog { + name + breed + } + ... on Cat { + name + livesRemaining + } + } + } + `); + + const result = await execute({ schema, document: query }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.pets).toBeDefined(); + expect(Array.isArray(result.data?.pets)).toBe(true); + + const pets = result.data?.pets as Array<{ __typename: string; name: string }>; + expect(pets.length).toBeGreaterThan(0); + + for (const pet of pets) { + expect(pet.__typename).toBeDefined(); + expect(['Dog', 'Cat']).toContain(pet.__typename); + expect(pet.name).toBeDefined(); + } + }); + + it('should resolve nodes with __typename', async () => { + const query = parse(` + query { + nodes { + __typename + id + ... on Person { + name + } + ... on Company { + companyName + } + } + } + `); + + const result = await execute({ schema, document: query }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.nodes).toBeDefined(); + expect(Array.isArray(result.data?.nodes)).toBe(true); + + const nodes = result.data?.nodes as Array<{ __typename: string; id: string }>; + expect(nodes.length).toBeGreaterThan(0); + + for (const node of nodes) { + expect(node.__typename).toBeDefined(); + expect(['Person', 'Company']).toContain(node.__typename); + expect(node.id).toBeDefined(); + } + }); + + it('should resolve single pet with __typename', async () => { + const query = parse(` + query { + pet(name: "Fido") { + __typename + ... on Dog { + name + breed + } + } + } + `); + + const result = await execute({ schema, document: query }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.pet).toBeDefined(); + + const pet = result.data?.pet as { __typename: string; name: string; breed: string }; + expect(pet.__typename).toBe('Dog'); + expect(pet.name).toBe('Fido'); + expect(pet.breed).toBe('Golden Retriever'); + }); +}); diff --git a/examples/required-typename/src/schema.ts b/examples/required-typename/src/schema.ts new file mode 100644 index 000000000..47d19de81 --- /dev/null +++ b/examples/required-typename/src/schema.ts @@ -0,0 +1,143 @@ +import { builder } from './builder'; + +// Define data types +interface Dog { + __typename: 'Dog'; + name: string; + breed: string; +} + +interface Cat { + __typename: 'Cat'; + name: string; + livesRemaining: number; +} + +interface Person { + id: string; + name: string; +} + +interface Company { + id: string; + companyName: string; +} + +// Sample data +const dogs: Dog[] = [ + { __typename: 'Dog', name: 'Fido', breed: 'Golden Retriever' }, + { __typename: 'Dog', name: 'Rex', breed: 'German Shepherd' }, +]; + +const cats: Cat[] = [ + { __typename: 'Cat', name: 'Whiskers', livesRemaining: 7 }, + { __typename: 'Cat', name: 'Mittens', livesRemaining: 9 }, +]; + +const people: Person[] = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, +]; + +const companies: Company[] = [ + { id: '1', companyName: 'Acme Corp' }, + { id: '2', companyName: 'TechStart Inc' }, +]; + +// Define object types +const DogType = builder.objectRef('Dog').implement({ + fields: (t) => ({ + name: t.exposeString('name'), + breed: t.exposeString('breed'), + }), +}); + +const CatType = builder.objectRef('Cat').implement({ + fields: (t) => ({ + name: t.exposeString('name'), + livesRemaining: t.exposeInt('livesRemaining'), + }), +}); + +const PersonType = builder.objectRef('Person').implement({ + fields: (t) => ({ + id: t.exposeID('id'), + name: t.exposeString('name'), + }), +}); + +const CompanyType = builder.objectRef('Company').implement({ + fields: (t) => ({ + id: t.exposeID('id'), + companyName: t.exposeString('companyName'), + }), +}); + +// Create a union with requiredTypename - chained immediately +// When __typename is provided, no resolveType is required +const PetUnion = builder + .unionType('Pet', { + types: [DogType, CatType], + }) + .requiredTypename(); + +// Create an interface with requiredTypename - chained immediately +const NodeInterface = builder + .interfaceRef<{ id: string }>('Node') + .implement({ + fields: (t) => ({ + id: t.exposeID('id'), + }), + }) + .requiredTypename(); + +// Implement the interface on types (by re-implementing with interface) +builder.objectType(PersonType, { + interfaces: [NodeInterface], + fields: (_t) => ({}), +}); + +builder.objectType(CompanyType, { + interfaces: [NodeInterface], + fields: (_t) => ({}), +}); + +// Define Query type +builder.queryType({ + fields: (t) => ({ + // Field returning union - TypeScript will require __typename in resolver + pets: t.field({ + type: [PetUnion], + resolve: () => { + // The return type here requires __typename to be present + // TypeScript will enforce this at compile time + return [...dogs, ...cats]; + }, + }), + // Field returning interface - TypeScript will require __typename in resolver + nodes: t.field({ + type: [NodeInterface], + resolve: () => { + // TypeScript requires __typename here, but we need to add it since + // our data doesn't have it naturally + return [ + ...people.map((p) => ({ ...p, __typename: 'Person' as const })), + ...companies.map((c) => ({ ...c, __typename: 'Company' as const })), + ]; + }, + }), + pet: t.field({ + type: PetUnion, + nullable: true, + args: { + name: t.arg.string({ required: true }), + }, + resolve: (_, args) => { + const allPets = [...dogs, ...cats]; + return allPets.find((pet) => pet.name === args.name) ?? null; + }, + }), + }), +}); + +export const schema = builder.toSchema(); diff --git a/examples/required-typename/src/server.ts b/examples/required-typename/src/server.ts new file mode 100644 index 000000000..e1c6264f1 --- /dev/null +++ b/examples/required-typename/src/server.ts @@ -0,0 +1,41 @@ +import { createServer } from 'node:http'; +import { createYoga } from 'graphql-yoga'; +import { schema } from './schema'; + +const yoga = createYoga({ + schema, + graphiql: { + defaultQuery: ` +query ExampleQuery { + pets { + __typename + ... on Dog { + name + breed + } + ... on Cat { + name + livesRemaining + } + } + nodes { + __typename + id + ... on Person { + name + } + ... on Company { + companyName + } + } +} + `.trim(), + }, +}); + +const server = createServer(yoga); + +const port = 4000; +server.listen(port, () => { + console.log(`🚀 Server ready at http://localhost:${port}/graphql`); +}); diff --git a/examples/required-typename/tsconfig.json b/examples/required-typename/tsconfig.json new file mode 100644 index 000000000..af181aed3 --- /dev/null +++ b/examples/required-typename/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf7a9802a..ca00a300a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,18 @@ importers: specifier: ^6.15.0 version: 6.15.0(typescript@5.9.2) + examples/required-typename: + dependencies: + '@pothos/core': + specifier: workspace:* + version: link:../../packages/core + graphql: + specifier: ^16.10.0 + version: 16.11.0 + graphql-yoga: + specifier: 5.15.2 + version: 5.15.2(graphql@16.11.0) + examples/simple-classes: dependencies: '@faker-js/faker':