├── .github ├── graphql-megaera.svg └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── cli.ts ├── generate.test.ts ├── generate.ts ├── index.test.ts ├── index.ts ├── utils.ts └── visitor.ts └── tsconfig.json /.github/graphql-megaera.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: 22.x 13 | - run: npm i 14 | - run: npm test 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Webpod 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Megaera 2 | 3 |

4 | GraphQL Megaera
5 | GraphQL to TypeScript Generator

6 | npm test 7 |

8 | 9 | ## Example 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 67 | 117 | 118 |
From GraphQLTo TypeScript
18 | 19 | ```graphql 20 | query IssuesQuery { 21 | issues(first: 100) { 22 | totalCount 23 | nodes { 24 | createdAt 25 | closedAt 26 | closed 27 | author { 28 | login 29 | } 30 | number 31 | title 32 | labels(first: 10) { 33 | totalCount 34 | nodes { 35 | name 36 | } 37 | } 38 | body 39 | comments(first: 10) { 40 | totalCount 41 | nodes { 42 | body 43 | } 44 | } 45 | repository { 46 | owner { 47 | login 48 | } 49 | name 50 | } 51 | } 52 | pageInfo { 53 | hasNextPage 54 | endCursor 55 | } 56 | } 57 | rateLimit { 58 | limit 59 | cost 60 | remaining 61 | resetAt 62 | } 63 | } 64 | ``` 65 | 66 | 68 | 69 | ```ts 70 | type IssuesQuery = () => { 71 | issues: { 72 | totalCount: number 73 | nodes: Array<{ 74 | createdAt: string 75 | closedAt: string | null 76 | closed: boolean 77 | author: { 78 | login: string 79 | } 80 | number: number 81 | title: string 82 | labels: { 83 | totalCount: number 84 | nodes: Array<{ 85 | name: string 86 | }> 87 | } 88 | body: string 89 | comments: { 90 | totalCount: number 91 | nodes: Array<{ 92 | body: string 93 | }> 94 | } 95 | repository: { 96 | owner: { 97 | login: string 98 | } 99 | name: string 100 | } 101 | }> 102 | pageInfo: { 103 | hasNextPage: boolean 104 | endCursor: string | null 105 | } 106 | } 107 | rateLimit: { 108 | limit: number 109 | cost: number 110 | remaining: number 111 | resetAt: string 112 | } 113 | } 114 | ``` 115 | 116 |
119 | 120 | ## Installation 121 | 122 | ```bash 123 | npm install megaera 124 | ``` 125 | 126 | ## Usage 127 | 128 | ```bash 129 | megaera --schema=schema.graphql ./src/**/*.graphql 130 | ``` 131 | 132 | Megaera will generate TypeScript code for all queries in the specified files. 133 | 134 | ## FAQ 135 | 136 |
137 | How to use Megaera? 138 | 139 | Put your queries in `.graphql` files, and run `megaera` to generate TypeScript code from them. 140 | 141 | Megaera will copy the query string to the generated TypeScript file, so you can 142 | import it in your TypeScript code. 143 | 144 | ```ts 145 | import { IssuesQuery } from './query.graphql.ts' 146 | ``` 147 | 148 | The `IssuesQuery` variable is a string with the GraphQL query. You can use it 149 | directly in your code, or pass it to a function that accepts a query. 150 | 151 | Also, `IssuesQuery` carries the type of the query, so you can use it to infer 152 | the return type of the query, and the types of the input variables. 153 | 154 | ```ts 155 | type Result = ReturnType 156 | ``` 157 | 158 | The type `IssuesQuery` can also be used independently: 159 | 160 | ```ts 161 | import type { IssuesQuery } from './query.graphql.ts' 162 | ``` 163 | 164 |
165 | 166 |
167 | How to get the return type of a query? 168 | 169 | Megaera generates TypeScript types for queries as functions. 170 | 171 | ```ts 172 | type UserQuery = (vars: { login?: string }) => { 173 | user: { 174 | login: string 175 | avatarUrl: string 176 | name: string 177 | } 178 | } 179 | ``` 180 | 181 | To get the return type of a query, use the `ReturnType` utility type: 182 | 183 | ```ts 184 | type Result = ReturnType 185 | ``` 186 | 187 |
188 | 189 |
190 | How to get the types of the variables of a query? 191 | 192 | The first parameter of the query function is the variables. 193 | 194 | You can use TypeScript's `Parameters` utility type to get the types of the variables: 195 | 196 | ```ts 197 | type Variables = Parameters[0] 198 | ``` 199 | 200 | Or you can use the `Variables` utility type to get the types of the variables: 201 | 202 | ```ts 203 | import { Variables } from 'megaera' 204 | 205 | type Variables = Variables 206 | ``` 207 | 208 |
209 | 210 |
211 | Why query string is copied to TypeScript file as well? 212 | 213 | To make it easier to import queries in TypeScript projects. As well to connect 214 | generated output types with query source code. 215 | 216 | This allows for library authors to create a function that accepts a query, and 217 | infers the return type from the query, as well as the types of the variables. 218 | 219 | For example, wrap [Octokit](https://github.com/octokit/octokit.js) in a function 220 | that accepts a query and returns the result: 221 | 222 | ```ts 223 | import { Query, Variables } from 'megaera' 224 | import { IssuesQuery } from './query.graphql.ts' 225 | 226 | function query(query: T, variables?: Variables) { 227 | return octokit.graphql>(query, variables) 228 | } 229 | 230 | // Return type, and types of variables are inferred from the query. 231 | const { issues } = await query(IssuesQuery, { login: 'webpod' }) 232 | ``` 233 | 234 |
235 | 236 |
237 | Does Megaera support fragments? 238 | 239 | Yes, Megaera fully supports fragments. Fragments are generated as separate types, 240 | and can be used independently. 241 | 242 | ```graphql 243 | query IssuesQuery($login: String) { 244 | issues(login: $login) { 245 | totalCount 246 | nodes { 247 | ...Issue 248 | } 249 | } 250 | } 251 | 252 | fragment Issue on Issue { 253 | number 254 | author { 255 | login 256 | } 257 | createdAt 258 | closedAt 259 | } 260 | ``` 261 | 262 | The generated TypeScript code will have a type `Issue` that can be used independently: 263 | 264 | ```ts 265 | import { Issue, IssuesQuery } from './query.graphql.ts' 266 | 267 | const firstIssue: Issue = query(IssuesQuery).issues.nodes[0] 268 | ``` 269 | 270 |
271 | 272 |
273 | Can Megaera extract queries from `.ts` files? 274 | 275 | No. To simplify development of Megaera, it is only possible to extract queries 276 | from `.graphql` files. 277 | 278 | But it should be possible to create plugins for webpack, rollup, or other 279 | bundlers that can extract queries from `.ts` files. If you are interested in 280 | this, please open an issue. 281 | 282 |
283 | 284 | ## License 285 | 286 | [MIT](LICENSE) 287 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "megaera", 3 | "version": "1.0.2", 4 | "description": "GraphQL to TypeScript Generator", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "bin": { 9 | "megaera": "./dist/cli.js" 10 | }, 11 | "scripts": { 12 | "fmt": "prettier --write .", 13 | "fmt:check": "prettier --check .", 14 | "build": "tsc", 15 | "test": "vitest", 16 | "prepublishOnly": "npm run build" 17 | }, 18 | "dependencies": { 19 | "graphql": "^16.0.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.14.10", 23 | "prettier": "^3.3.2", 24 | "typescript": "^5.5.3", 25 | "vitest": "^2.0.1" 26 | }, 27 | "files": [ 28 | "dist" 29 | ], 30 | "prettier": { 31 | "semi": false, 32 | "singleQuote": true, 33 | "endOfLine": "lf" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/webpod/graphql-megaera.git" 38 | }, 39 | "keywords": [ 40 | "graphql", 41 | "typescript", 42 | "generator" 43 | ], 44 | "author": "Anton Medvedev ", 45 | "license": "MIT", 46 | "homepage": "https://github.com/webpod/graphql-megaera" 47 | } 48 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import process from 'node:process' 4 | import * as os from 'node:os' 5 | import * as fs from 'node:fs' 6 | import * as path from 'node:path' 7 | import { buildSchema } from 'graphql/utilities/index.js' 8 | import { Source } from 'graphql/language/index.js' 9 | import { GraphQLSchema } from 'graphql/type/index.js' 10 | import { GraphQLError } from 'graphql/error/index.js' 11 | import { styleText } from 'node:util' 12 | import { traverse } from './visitor.js' 13 | import { generate } from './generate.js' 14 | import { plural } from './utils.js' 15 | 16 | void (async function main() { 17 | let schemaFileOrUrl: string | undefined 18 | let inputFiles: string[] = [] 19 | 20 | const args = process.argv.slice(2) 21 | if (args.length === 0) { 22 | usage() 23 | return 24 | } 25 | for (let i = 0; i < args.length; i++) { 26 | const arg = args[i] 27 | if (arg === '--help' || arg === '-h') { 28 | usage() 29 | return 30 | } else if (arg == '--schema') { 31 | schemaFileOrUrl = args[++i] 32 | } else if (arg.startsWith('--schema=')) { 33 | schemaFileOrUrl = arg.slice('--schema='.length) 34 | } else if (arg.startsWith('--')) { 35 | console.error(`Unknown flag: ${arg}`) 36 | process.exitCode = 1 37 | return 38 | } else { 39 | inputFiles.push(arg) 40 | } 41 | } 42 | 43 | if (schemaFileOrUrl === undefined) { 44 | console.error( 45 | `Missing schema file or URL. Use --schema= flag to specify it.`, 46 | ) 47 | process.exitCode = 1 48 | return 49 | } 50 | if (inputFiles.length === 0) { 51 | console.error( 52 | `Missing input files. Specify input files ./src/**/*.graphql to generate types.`, 53 | ) 54 | process.exitCode = 1 55 | return 56 | } 57 | 58 | schemaFileOrUrl = homeDirExpand(schemaFileOrUrl) 59 | inputFiles = inputFiles.map((f) => homeDirExpand(f)) 60 | 61 | let schemaSource: string 62 | if (/https?:\/\//.test(schemaFileOrUrl)) { 63 | const headers = new Headers() 64 | if (process.env.GITHUB_TOKEN) { 65 | const token = process.env.GITHUB_TOKEN 66 | headers.set('Authorization', `Bearer ${token}`) 67 | } 68 | const using = headers.has('Authorization') ? ` using $GITHUB_TOKEN` : `` 69 | console.log(`Fetching schema from ${schemaFileOrUrl}${using}.`) 70 | schemaSource = await fetch(schemaFileOrUrl, { headers }).then((r) => 71 | r.text(), 72 | ) 73 | } else { 74 | schemaSource = fs.readFileSync(schemaFileOrUrl, 'utf-8') 75 | } 76 | 77 | let schema: GraphQLSchema 78 | try { 79 | schema = buildSchema(schemaSource) 80 | } catch (e) { 81 | console.error( 82 | styleText(['bgRed', 'whiteBright', 'bold'], `Failed to parse schema`), 83 | ) 84 | throw e 85 | } 86 | 87 | for (let inputFile of inputFiles) { 88 | const dirName = path.dirname(inputFile) 89 | const fileName = path.basename(inputFile) 90 | 91 | console.log(`Processing ${inputFile}`) 92 | 93 | const source = new Source(fs.readFileSync(inputFile, 'utf-8'), fileName) 94 | const content = traverse(schema, source) 95 | const code = generate(content) 96 | 97 | const ops = plural( 98 | content.operations.length, 99 | '%d operation', 100 | '%d operations', 101 | ) 102 | const frg = plural(content.fragments.size, '%d fragment', '%d fragments') 103 | console.log(`> ${styleText('green', 'done')} (${ops}, ${frg})`) 104 | 105 | const prefix = `// DO NOT EDIT. This is a generated file. Instead of this file, edit "${fileName}".\n\n` 106 | fs.writeFileSync( 107 | path.join(dirName, fileName + '.ts'), 108 | prefix + code, 109 | 'utf-8', 110 | ) 111 | } 112 | })().catch((e) => { 113 | if (e instanceof GraphQLError) { 114 | console.error(e.toString()) 115 | process.exitCode = 1 116 | } else { 117 | throw e 118 | } 119 | }) 120 | 121 | function usage() { 122 | console.log(`Usage: megaera [options] `) 123 | console.log(`Options:`) 124 | console.log(` --schema= GraphQL schema file or URL`) 125 | process.exitCode = 2 126 | } 127 | 128 | function homeDirExpand(file: string) { 129 | if (file.startsWith('~')) { 130 | return path.join(os.homedir(), file.slice(1)) 131 | } 132 | return file 133 | } 134 | -------------------------------------------------------------------------------- /src/generate.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest' 2 | import { buildSchema, typeFromAST } from 'graphql/utilities/index.js' 3 | import { generateType } from './generate.js' 4 | import { isObjectType } from 'graphql/type/index.js' 5 | 6 | const schema = buildSchema(` 7 | type User { 8 | name: String! 9 | birthday: Date! 10 | gender: Gender! 11 | favoriteBooks: [String] 12 | } 13 | 14 | type Object { 15 | name: String! 16 | age: Int 17 | } 18 | 19 | scalar Date 20 | 21 | enum Gender { 22 | MALE 23 | FEMALE 24 | } 25 | `) 26 | 27 | function getFieldType(name: string, fieldName: string) { 28 | const type = schema.getType(name) 29 | if (!isObjectType(type)) { 30 | throw new Error(`${name} is not object type.`) 31 | } 32 | const field = type.astNode?.fields?.find( 33 | (f) => f.name.value === fieldName, 34 | )?.type 35 | if (!field) { 36 | throw new Error(`Cannot find ${fieldName} field in ${name}.`) 37 | } 38 | return typeFromAST(schema, field) 39 | } 40 | 41 | test('scalar type', () => { 42 | const birthdayType = getFieldType('User', 'birthday') 43 | const code = generateType(birthdayType) 44 | expect(code).toEqual('string') 45 | }) 46 | 47 | test('enum type', () => { 48 | const code = generateType(schema.getType('Gender')) 49 | expect(code).toEqual(`'MALE' | 'FEMALE'`) 50 | }) 51 | 52 | test('list type', () => { 53 | const favoriteBooksType = getFieldType('User', 'favoriteBooks') 54 | const code = generateType(favoriteBooksType) 55 | expect(code).toEqual('(string | null)[] | null') 56 | }) 57 | 58 | test('object type', () => { 59 | const code = generateType(schema.getType('Object')) 60 | expect(code).toEqual('{name: string, age: number | null}') 61 | }) 62 | -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLOutputType, 3 | GraphQLType, 4 | isEnumType, 5 | isListType, 6 | isNonNullType, 7 | isNullableType, 8 | isScalarType, 9 | } from 'graphql/type/definition.js' 10 | import { Content, Selector, Variable } from './visitor.js' 11 | import { isObjectType } from 'graphql/type/index.js' 12 | 13 | export function generate(content: Content) { 14 | const code: string[] = [] 15 | for (const f of content.fragments.values()) { 16 | code.push(`const ${f.name} = \`#graphql 17 | ${f.source}\` 18 | 19 | export type ${f.name} = ${generateSelector(f, 0, true)} 20 | `) 21 | } 22 | 23 | for (const q of content.operations) { 24 | if (!q.source) { 25 | throw new Error('Empty query source for operation ' + q.name) 26 | } 27 | 28 | let querySource = q.source 29 | 30 | for (const fName of usedFragments(q, content)) { 31 | querySource = '${' + fName + '}\n' + querySource 32 | } 33 | 34 | code.push(`export const ${q.name} = \`#graphql 35 | ${querySource}\` as string & ${q.name} 36 | 37 | export type ${q.name} = (${generateVariables(q.variables)}) => ${generateSelector(q, 0, true)} 38 | `) 39 | } 40 | 41 | return code.join('\n') 42 | } 43 | 44 | function usedFragments(q: Selector, content: Content): string[] { 45 | const fragments: string[] = [] 46 | for (const field of q.fields) { 47 | if (field.isFragment) { 48 | fragments.push(field.name) 49 | const fragment = content.fragments.get(field.name) 50 | if (!fragment) { 51 | throw new Error(`Fragment ${field.name} is not defined.`) 52 | } 53 | fragments.push(...usedFragments(fragment, content)) 54 | } 55 | fragments.push(...usedFragments(field, content)) 56 | } 57 | for (const inlineFragment of q.inlineFragments) { 58 | fragments.push(...usedFragments(inlineFragment, content)) 59 | } 60 | return fragments 61 | } 62 | 63 | function generateVariables(variables?: Variable[]) { 64 | if (!variables || variables.length === 0) { 65 | return '' 66 | } 67 | return ( 68 | 'vars: { ' + 69 | variables 70 | .map((v) => { 71 | return v.name + (v.required ? '' : '?') + ': ' + generateType(v.type) 72 | }) 73 | .join(', ') + 74 | ' }' 75 | ) 76 | } 77 | 78 | function generateSelector(s: Selector, depth = 0, nonNull = false): string { 79 | if (s.fields.length === 0 && s.inlineFragments.length === 0) { 80 | return generateType(s.type) 81 | } 82 | 83 | const code = 84 | generateFields(s, depth) + 85 | generateFragments(s) + 86 | generateInlineFragments(s, depth - 1) 87 | 88 | if (isNonNullType(s.type)) { 89 | return wrapInArray(s.type.ofType, code) 90 | } 91 | 92 | return ( 93 | wrapInArray(s.type, code) + 94 | (isNullableType(s.type) && !nonNull ? ' | null' : '') 95 | ) 96 | } 97 | 98 | function wrapInArray(t: GraphQLOutputType | undefined, code: string) { 99 | return isListType(t) ? `Array<${code}>` : code 100 | } 101 | 102 | function generateFragments(s: Selector): string { 103 | let code = '' 104 | for (const fragment of s.fields) { 105 | if (!fragment.isFragment) continue 106 | code += ' & ' + fragment.name 107 | } 108 | return code 109 | } 110 | 111 | function generateFields(s: Selector, depth: number): string { 112 | const nonFragmentFields = s.fields.filter((f) => !f.isFragment) 113 | if (nonFragmentFields.length === 0) { 114 | return '{}' 115 | } 116 | 117 | const code: string[] = [] 118 | code.push('{') 119 | for (const field of nonFragmentFields) { 120 | code.push( 121 | ' '.repeat(depth + 1) + 122 | field.name + 123 | ': ' + 124 | generateSelector(field, depth + 1), 125 | ) 126 | } 127 | code.push(' '.repeat(depth) + '}') 128 | return code.join('\n') 129 | } 130 | 131 | function generateInlineFragments(s: Selector, depth: number) { 132 | let code = '' 133 | let nullable = false 134 | for (const fragment of s.inlineFragments) { 135 | code += ' & ' + generateSelector(fragment, depth + 1, true) 136 | nullable ||= isNullableType(fragment.type) 137 | } 138 | return code + (nullable ? ' | null' : '') 139 | } 140 | 141 | export function generateType(t?: GraphQLType, orNull = ' | null'): string { 142 | if (t === undefined) { 143 | return 'unknown' 144 | } 145 | if (isNonNullType(t)) { 146 | return generateType(t.ofType, '') 147 | } 148 | if (isListType(t)) { 149 | const subType = generateType(t.ofType) 150 | if (subType.includes(' ')) { 151 | return '(' + subType + ')[]' + orNull 152 | } 153 | return subType + '[]' + orNull 154 | } 155 | if (isEnumType(t)) { 156 | return t 157 | .getValues() 158 | .map((v) => `'${v.value}'`) 159 | .join(' | ') 160 | } 161 | if (isObjectType(t)) { 162 | const code: string[] = [] 163 | for (const field of Object.values(t.getFields())) { 164 | code.push(field.name + ': ' + generateType(field.type)) 165 | } 166 | return '{' + code.join(', ') + '}' 167 | } 168 | if (isScalarType(t)) { 169 | switch (t.name) { 170 | case 'String': 171 | return 'string' + orNull 172 | case 'Int': 173 | return 'number' + orNull 174 | case 'Float': 175 | return 'number' + orNull 176 | case 'Boolean': 177 | return 'boolean' + orNull 178 | default: 179 | return 'string' + orNull 180 | } 181 | } 182 | throw new Error(`Cannot generate TypeScript type from GraphQL type "${t}".`) 183 | } 184 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest' 2 | import { Source } from 'graphql/language/index.js' 3 | import { buildSchema } from 'graphql/utilities/index.js' 4 | import { transpile } from './index.js' 5 | 6 | const schema = buildSchema(` 7 | schema { 8 | query: Query 9 | mutation: Mutation 10 | } 11 | 12 | type Query { 13 | User(login: String!): User 14 | Users: [User!] 15 | NonNullUsers: [User!]! 16 | } 17 | 18 | type User { 19 | name: String 20 | avatarUrl: String 21 | favoriteAnimal: Animal 22 | } 23 | 24 | type Mutation { 25 | CreateUser(name: String, avatarUrl: String!): User 26 | } 27 | 28 | union Animal = User | Dog 29 | 30 | type Dog { 31 | name: String 32 | barks: Boolean 33 | } 34 | 35 | type Cat { 36 | name: String 37 | meows: Boolean 38 | } 39 | `) 40 | 41 | test('optional variable with default value', () => { 42 | const source = new Source(` 43 | query User($login: String! = "antonmedv") { 44 | user(login: $login) { 45 | name 46 | } 47 | } 48 | `) 49 | const code = transpile(schema, source) 50 | expect(code).includes('type User = (vars: { login?: string })') 51 | }) 52 | 53 | test('optional variable with nullable type', () => { 54 | const source = new Source(` 55 | query User($login: String) { 56 | user(login: $login) { 57 | name 58 | } 59 | } 60 | `) 61 | const code = transpile(schema, source) 62 | expect(code).includes('type User = (vars: { login?: string | null })') 63 | }) 64 | 65 | test('multiple queries', () => { 66 | const source = new Source(` 67 | query User1 { 68 | user(login: "anton") { 69 | name 70 | } 71 | } 72 | 73 | query User2 { 74 | user(login: "anna") { 75 | name 76 | } 77 | } 78 | `) 79 | const code = transpile(schema, source) 80 | expect(code).includes('type User1') 81 | expect(code).includes('type User2') 82 | }) 83 | 84 | test('mutations', () => { 85 | const source = new Source(` 86 | mutation CreateUser($name: String!, $avatarUrl: String) { 87 | createUser(name: $name, avatarUrl: $avatarUrl) { 88 | name 89 | } 90 | } 91 | `) 92 | const code = transpile(schema, source) 93 | expect(code).includes( 94 | 'type CreateUser = (vars: { name: string, avatarUrl?: string | null }) =>', 95 | ) 96 | }) 97 | 98 | test('fragments', () => { 99 | const source = new Source(` 100 | fragment UserName on User { 101 | name 102 | } 103 | 104 | query User { 105 | user(login: "antonmedv") { 106 | ...UserName 107 | } 108 | } 109 | `) 110 | const code = transpile(schema, source) 111 | expect(code).includes('name: string | null\n') 112 | expect(code).includes('type UserName = {\n name: string | null\n}\n') 113 | }) 114 | 115 | test('fragments with variables', () => { 116 | const source = new Source(` 117 | query User($login: String!) { 118 | ...UserName 119 | } 120 | 121 | fragment UserName on Query { 122 | user(login: $login) { 123 | name 124 | } 125 | } 126 | `) 127 | const code = transpile(schema, source) 128 | expect(code).includes( 129 | 'const UserName = `#graphql\nfragment UserName on Query', 130 | ) 131 | }) 132 | 133 | test('inline interfaces', () => { 134 | const source = new Source(` 135 | query User { 136 | user(login: "antonmedv") { 137 | favoriteAnimal { 138 | __typename 139 | ...on Dog { 140 | name 141 | barks 142 | } 143 | ...on Cat { 144 | name 145 | meows 146 | } 147 | } 148 | } 149 | } 150 | `) 151 | const code = transpile(schema, source) 152 | expect(code).includes('__typename: unknown\n') 153 | expect(code).includes('barks: boolean | null\n') 154 | expect(code).includes('meows: boolean | null\n') 155 | }) 156 | 157 | test('query with list', () => { 158 | const source = new Source(` 159 | query Nullable { 160 | Users { 161 | name 162 | } 163 | } 164 | `) 165 | const code = transpile(schema, source) 166 | expect(code).includes(`{ 167 | Users: Array<{ 168 | name: string | null 169 | }> | null 170 | }`) 171 | }) 172 | 173 | test('query with non-null list', () => { 174 | const source = new Source(` 175 | query NonNullable { 176 | NonNullUsers { 177 | name 178 | } 179 | } 180 | `) 181 | const code = transpile(schema, source) 182 | expect(code).includes(`{ 183 | NonNullUsers: Array<{ 184 | name: string | null 185 | }> 186 | }`) 187 | }) 188 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Source } from 'graphql/language/index.js' 2 | import { generate } from './generate.js' 3 | import { traverse } from './visitor.js' 4 | import { GraphQLSchema } from 'graphql/type/index.js' 5 | 6 | // Transpile a GraphQL schema and source to a TypeScript file. 7 | export function transpile(schema: GraphQLSchema, source: Source) { 8 | return generate(traverse(schema, source)) 9 | } 10 | 11 | // Query is a GraphQL query string with type information attached. 12 | // Parameters of the query are the variables. The return type is the 13 | // result of the query. 14 | export type Query = string & ((vars: any) => any) 15 | 16 | // Variables of a query are the first parameter of the query function. 17 | export type Variables = Parameters[0] 18 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function firstLetterUpper(string: string) { 2 | return string.charAt(0).toUpperCase() + string.slice(1) 3 | } 4 | 5 | export function plural(count: number, singular: string, plural: string) { 6 | return (count === 1 ? singular : plural).replace('%d', count.toString()) 7 | } 8 | -------------------------------------------------------------------------------- /src/visitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLOutputType, 3 | GraphQLType, 4 | isNonNullType, 5 | } from 'graphql/type/definition.js' 6 | import { 7 | typeFromAST, 8 | TypeInfo, 9 | visitWithTypeInfo, 10 | } from 'graphql/utilities/index.js' 11 | import { parse, print, Source, visit } from 'graphql/language/index.js' 12 | import { GraphQLSchema } from 'graphql/type/index.js' 13 | import { GraphQLError } from 'graphql/error/index.js' 14 | import { firstLetterUpper } from './utils.js' 15 | 16 | export type Variable = { 17 | name: string 18 | type: GraphQLType | undefined 19 | required: boolean 20 | } 21 | 22 | export type Selector = { 23 | name: string 24 | type?: GraphQLOutputType 25 | fields: Selector[] 26 | inlineFragments: Selector[] 27 | variables?: Variable[] 28 | isFragment?: boolean 29 | source?: string 30 | } 31 | 32 | export type Content = { 33 | operations: Selector[] 34 | fragments: Map 35 | } 36 | 37 | export function traverse(schema: GraphQLSchema, source: Source): Content { 38 | const ast = parse(source) 39 | const typeInfo = new TypeInfo(schema) 40 | 41 | const content: Content = { 42 | operations: [], 43 | fragments: new Map(), 44 | } 45 | 46 | const stack: Selector[] = [] 47 | 48 | const visitor = visitWithTypeInfo(typeInfo, { 49 | OperationDefinition: { 50 | enter: function (node) { 51 | if (node.name === undefined) { 52 | throw new GraphQLError( 53 | firstLetterUpper(node.operation) + ' name is required', 54 | node, 55 | source, 56 | ) 57 | } 58 | checkUnique(node.name.value, content) 59 | 60 | const variables: Variable[] = [] 61 | for (const v of node.variableDefinitions ?? []) { 62 | const type = typeFromAST(schema, v.type) 63 | variables.push({ 64 | name: v.variable.name.value, 65 | type: type, 66 | required: v.defaultValue === undefined && isNonNullType(type), 67 | }) 68 | } 69 | 70 | const s: Selector = { 71 | name: node.name.value, 72 | type: typeInfo.getType() ?? undefined, 73 | fields: [], 74 | inlineFragments: [], 75 | variables: variables, 76 | source: print(node), 77 | } 78 | 79 | stack.push(s) 80 | content.operations.push(s) 81 | }, 82 | leave() { 83 | stack.pop() 84 | }, 85 | }, 86 | 87 | FragmentDefinition: { 88 | enter(node) { 89 | checkUnique(node.name.value, content) 90 | 91 | const s: Selector = { 92 | name: node.name.value, 93 | type: typeInfo.getType() ?? undefined, 94 | fields: [], 95 | inlineFragments: [], 96 | source: print(node), 97 | } 98 | 99 | stack.push(s) 100 | content.fragments.set(s.name, s) 101 | }, 102 | leave() { 103 | stack.pop() 104 | }, 105 | }, 106 | 107 | Field: { 108 | enter(node) { 109 | const s: Selector = { 110 | name: node.alias?.value ?? node.name.value, 111 | type: typeInfo.getType() ?? undefined, 112 | fields: [], 113 | inlineFragments: [], 114 | } 115 | stack.at(-1)?.fields.push(s) 116 | stack.push(s) 117 | }, 118 | leave() { 119 | stack.pop() 120 | }, 121 | }, 122 | 123 | FragmentSpread: { 124 | enter(node) { 125 | stack.at(-1)?.fields.push({ 126 | name: node.name.value, 127 | type: typeInfo.getType() ?? undefined, 128 | isFragment: true, 129 | fields: [], 130 | inlineFragments: [], 131 | }) 132 | }, 133 | }, 134 | 135 | InlineFragment: { 136 | enter(node) { 137 | if (!node.typeCondition) { 138 | throw new GraphQLError( 139 | 'Inline fragment must have type condition.', 140 | node, 141 | source, 142 | ) 143 | } 144 | const s: Selector = { 145 | name: node.typeCondition.name.value, 146 | type: typeInfo.getType() ?? undefined, 147 | fields: [], 148 | inlineFragments: [], 149 | } 150 | stack.at(-1)?.inlineFragments.push(s) 151 | stack.push(s) 152 | }, 153 | leave() { 154 | stack.pop() 155 | }, 156 | }, 157 | }) 158 | 159 | visit(ast, visitor) 160 | 161 | return content 162 | } 163 | 164 | function checkUnique(name: string, content: Content) { 165 | if (content.operations.find((o) => o.name === name)) { 166 | throw new GraphQLError(`Operation with name "${name}" is already defined.`) 167 | } 168 | if (content.fragments.has(name)) { 169 | throw new GraphQLError(`Fragment with name "${name}" is already defined.`) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022", "DOM"], 5 | "moduleResolution": "NodeNext", 6 | "module": "NodeNext", 7 | "strict": true, 8 | "outDir": "./dist", 9 | "declaration": true, 10 | "allowJs": true 11 | }, 12 | "include": ["./src/**/*"] 13 | } 14 | --------------------------------------------------------------------------------