├── .gitignore ├── README.md ├── __tests__ └── index.test.mjs ├── package.json ├── src └── index.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL TOE (Throw On Error) 2 | 3 | > Like bumping your **toe** on something... I usually **throw** things! 4 | > -- Pascal Senn, ChilliCream 5 | 6 | **GraphQL gives you `null`... Was that a real `null`, or an error?** 7 | 8 | TOE makes GraphQL errors into real JavaScript errors, so you can stop writing 9 | code that second-guesses your data! 10 | 11 | Works seamlessly with `try`/`catch`, or your framework's error handling such as 12 | `` in React or SolidJS. And, with semantic nullability, reduce 13 | the need for null checks in your client code! 14 | 15 | ## Example 16 | 17 | ```ts 18 | import { toe } from "graphql-toe"; 19 | 20 | // Imagine the second user threw an error in your GraphQL request: 21 | const result = await request("/graphql", "{ users(first: 2) { id } }"); 22 | 23 | // Take the GraphQL response map and convert it into a TOE object: 24 | const data = toe(result); 25 | 26 | data.users[0]; // { id: 1 } 27 | data.users[1]; // Throws "Loading user 2 failed!" 28 | ``` 29 | 30 | ## How? 31 | 32 | Returns a copy of your GraphQL result data that uses 33 | [getters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) 34 | to throw an error when you read from an errored GraphQL field. And it's 35 | efficient: only the parts of the response that are impacted by errors are copied 36 | (if there are no errors, the underlying data is returned directly). 37 | 38 | ## Why? 39 | 40 | GraphQL replaces errored fields with `null`, so you can't trust a `null` to mean 41 | "nothing"; you must always check to see if a `null` actually represents an error 42 | from the "errors" list. 43 | 44 | `toe()` fixes this. It reintroduces errors into your data using getters that 45 | throw when accessed. 46 | 47 | That means: 48 | 49 | - `try`/`catch` just works 50 | - `` components can catch data-layer errors 51 | - Your GraphQL types’ [_semantic_ nullability](#semantic-nullability) matters 52 | again 53 | 54 | ## Installation 55 | 56 | ```bash 57 | yarn add graphql-toe 58 | # OR: npm install --save graphql-toe 59 | # OR: pnpm install --save graphql-toe 60 | ``` 61 | 62 | ## Usage 63 | 64 | ```ts 65 | import { toe } from "graphql-toe"; 66 | 67 | const result = await fetch(/* ... */).then((res) => res.json()); 68 | const data = toe(result); 69 | ``` 70 | 71 | If `result.data` is `null` or not present, `toe(result)` will throw immediately. 72 | Otherwise, `data` is a derivative of `result.data` where errored fields are 73 | replaced with throwing getters. 74 | 75 | ## Framework examples 76 | 77 | How to get `result` and feed it to `toe(result)` will depend on the client 78 | you're using. Here are some examples: 79 | 80 | ### Apollo Client (React) 81 | 82 | ```ts 83 | import { useQuery } from "@apollo/client"; 84 | import { toe } from "graphql-toe"; 85 | import { useMemo } from "react"; 86 | 87 | function useQueryTOE(document, options) { 88 | const rawResult = useQuery(document, { ...options, errorPolicy: "all" }); 89 | return useMemo( 90 | () => toe({ data: rawResult.data, errors: rawResult.error?.graphQLErrors }), 91 | [rawResult.data, rawResult.error], 92 | ); 93 | } 94 | ``` 95 | 96 | Note: apply similar changes to mutations and subscriptions. 97 | 98 | ### URQL 99 | 100 | Use 101 | [@urql/exchange-throw-on-error](https://github.com/urql-graphql/urql/tree/main/exchanges/throw-on-error): 102 | 103 | ```ts 104 | import { Client, fetchExchange } from "urql"; 105 | import { throwOnErrorExchange } from "@urql/exchange-throw-on-error"; 106 | 107 | const client = new Client({ 108 | url: "/graphql", 109 | exchanges: [fetchExchange, throwOnErrorExchange()], 110 | }); 111 | ``` 112 | 113 | ### graffle 114 | 115 | ```ts 116 | import { request } from "graffle"; 117 | 118 | const result = await request("https://api.spacex.land/graphql/", document); 119 | const data = toe(result); 120 | ``` 121 | 122 | ### fetch() 123 | 124 | ```ts 125 | import { toe } from "graphql-toe"; 126 | 127 | const response = await fetch("/graphql", { 128 | headers: { 129 | Accept: "application/graphql-response+json, application/json", 130 | "Content-Type": "application/json", 131 | }, 132 | body: JSON.stringify({ query: "{ __schema { queryType { name } } }" }), 133 | }); 134 | if (!response.ok) throw new Error("Uh-oh!"); 135 | const result = await response.json(); 136 | const data = toe(result); 137 | ``` 138 | 139 | ### Relay 140 | 141 | Relay has native support for error handling via the 142 | [@throwOnFieldError](https://relay.dev/docs/guides/throw-on-field-error-directive/) 143 | and [@catch](https://relay.dev/docs/guides/catch-directive/) directives. 144 | 145 | ## Zero dependencies 146 | 147 | **Just 512 bytes** gzipped 148 | ([v1.0.0-rc.0 on bundlephobia](https://bundlephobia.com/package/graphql-toe@1.0.0-rc.0)) 149 | 150 | Works with _any_ GraphQL client that returns `{ data, errors }`. 151 | 152 | Errors are thrown as-is; you can pre-process them to wrap in `Error` or 153 | `GraphQLError` if needed: 154 | 155 | ```ts 156 | import { GraphQLError } from "graphql"; 157 | import { toe } from "graphql-toe"; 158 | 159 | const mappedResult = { 160 | ...result, 161 | errors: result.errors?.map( 162 | (e) => 163 | new GraphQLError(e.message, { 164 | positions: e.positions, 165 | path: e.path, 166 | originalError: e, 167 | extensions: e.extensions, 168 | }), 169 | ), 170 | }; 171 | const data = toe(mappedResult); 172 | ``` 173 | 174 | ## Semantic nullability 175 | 176 | The 177 | [@semanticNonNull](https://specs.apollo.dev/nullability/v0.4/#@semanticNonNull) 178 | directive lets schema designers mark fields where `null` is **never a valid 179 | value**; so if you see `null`, it means an error occurred. 180 | 181 | Normally this intent is lost and clients still need to check for `null`, but 182 | with `toe()` you can treat these fields as non-nullable: a `null` here will 183 | throw. 184 | 185 | In TypeScript, use 186 | [semanticToStrict from graphql-sock](https://github.com/graphile/graphql-sock?tab=readme-ov-file#semantic-to-strict) 187 | to rewrite semantic-non-null to traditional non-null for type generation. 188 | 189 | Together, this combination gives you: 190 | 191 | - More accurate codegen types 192 | - Improved DX with fewer null checks 193 | - Safer, cleaner client code 194 | 195 | ## Motivation 196 | 197 | On the server side, GraphQL captures errors, replaces them in the returned 198 | `data` with a `null`, and adds them to the `errors` array. Clients typically 199 | then have to look at `data` and `errors` in combination to determine if a `null` 200 | is a "true null" (just a `null` value) or an "error null" (a `null` with a 201 | matching error in the `errors` list). This is unwieldy. 202 | 203 | I see the future of GraphQL as errors being handled on the client side, and 204 | error propagation being disabled on the server. Over time, I hope all major 205 | GraphQL clients will integrate error handling deep into their architecture, but 206 | in the mean time this project can add support for this future behavior to almost 207 | any GraphQL client by re-introducing thrown errors into your data. Handle errors 208 | the way your programming language or framework is designed to — no need for 209 | GraphQL-specific logic. 210 | 211 | ## Deeper example 212 | 213 | ```ts 214 | import { toe } from "graphql-toe"; 215 | 216 | // Example data from GraphQL 217 | const result = { 218 | data: { 219 | deep: { 220 | withList: [ 221 | { int: 1 }, 222 | { 223 | /* `null` because an error occurred */ 224 | int: null, 225 | }, 226 | { int: 3 }, 227 | ], 228 | }, 229 | }, 230 | errors: [ 231 | { 232 | message: "Two!", 233 | // When you read from this path, an error will be thrown 234 | path: ["deep", "withList", 1, "int"], 235 | }, 236 | ], 237 | }; 238 | 239 | // TOE'd data: 240 | const data = toe(result); 241 | 242 | // Returns `3`: 243 | data.deep.withList[2].int; 244 | 245 | // Returns an object with the key `int` 246 | data.deep.withList[1]; 247 | 248 | // Throws the error `Two!` 249 | data.deep.withList[1].int; 250 | ``` 251 | 252 | ## TODO 253 | 254 | - [ ] Add support for incremental delivery 255 | 256 | ## History 257 | 258 | Version 0.1.0 of this module was released from the San Francisco Centre the day 259 | after GraphQLConf 2024, following many fruitful discussions around nullability. 260 | -------------------------------------------------------------------------------- /__tests__/index.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test } from "node:test"; 4 | import * as assert from "node:assert"; 5 | import { toe } from "../dist/index.js"; 6 | import { graphql, buildSchema } from "graphql"; 7 | 8 | const schema = buildSchema(/* GraphQL */ ` 9 | type Query { 10 | mol: Int 11 | mole: Int 12 | deep: Deep 13 | } 14 | type Deep { 15 | withList: [ListItem] 16 | } 17 | type ListItem { 18 | int: Int 19 | } 20 | `); 21 | 22 | const rootValue = { 23 | mol() { 24 | return 42; 25 | }, 26 | mole() { 27 | throw new Error("Fourty two!"); 28 | }, 29 | deep: { 30 | withList() { 31 | return [ 32 | { int: 1 }, 33 | { 34 | int: new Error("Two!"), 35 | }, 36 | { int: 3 }, 37 | new Error("Item 4"), 38 | ]; 39 | }, 40 | }, 41 | }; 42 | 43 | /** 44 | * Fn 45 | * 46 | * @param {string} source 47 | */ 48 | async function genResult(source) { 49 | return toe( 50 | await graphql({ 51 | schema, 52 | source, 53 | rootValue, 54 | }), 55 | ); 56 | } 57 | 58 | test("simple query", async () => { 59 | const data = await genResult(`{mol}`); 60 | assert.deepEqual(data, { 61 | mol: 42, 62 | }); 63 | }); 64 | 65 | test("simple error", async () => { 66 | const data = await genResult(`{mole}`); 67 | let err; 68 | try { 69 | console.log(data.mole); 70 | } catch (e) { 71 | err = e; 72 | } 73 | assert.ok(err); 74 | assert.deepEqual(err.path, ["mole"]); 75 | assert.deepEqual(err.message, "Fourty two!"); 76 | }); 77 | 78 | test("deep error", async () => { 79 | const data = 80 | /** @type {{deep: {withList: Array<{int: number}>}}} */ 81 | (await genResult(`{deep{withList{int}}}`)); 82 | assert.equal(data.deep.withList.length, 4); 83 | assert.deepEqual(data.deep.withList[0], { int: 1 }); 84 | assert.deepEqual(data.deep.withList[2], { int: 3 }); 85 | assert.ok(data.deep.withList[1]); 86 | assert.ok(typeof data.deep.withList[1], "object"); 87 | { 88 | let err; 89 | try { 90 | console.log(data.deep.withList[1].int); 91 | } catch (e) { 92 | err = e; 93 | } 94 | assert.ok(err); 95 | assert.deepEqual(err.path, ["deep", "withList", 1, "int"]); 96 | assert.deepEqual(err.message, "Two!"); 97 | } 98 | { 99 | let err; 100 | try { 101 | console.log(data.deep.withList[3]); 102 | } catch (e) { 103 | err = e; 104 | } 105 | assert.ok(err); 106 | assert.deepEqual(err.path, ["deep", "withList", 3]); 107 | assert.deepEqual(err.message, "Item 4"); 108 | } 109 | }); 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-toe", 3 | "version": "1.0.0-rc.0", 4 | "description": "GraphQL Throw-On-Error - incorporate error handling back into the reading of your data, so you can handle errors in the most natural way.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prepack": "tsc", 8 | "test": "yarn prepack && node --test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https+git://github.com/graphile/graphql-toe.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/graphile/graphql-toe/issues" 16 | }, 17 | "homepage": "https://github.com/graphile/graphql-toe", 18 | "keywords": [ 19 | "graphql", 20 | "throw", 21 | "error", 22 | "errors", 23 | "raise", 24 | "exception", 25 | "semantic", 26 | "null", 27 | "non-null", 28 | "nullability", 29 | "null", 30 | "only", 31 | "on", 32 | "error" 33 | ], 34 | "author": "Benjie Gillam ", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@tsconfig/recommended": "^1.0.7", 38 | "@types/node": "^22.5.4", 39 | "graphql": "^16.9.0", 40 | "prettier": "^3.3.3", 41 | "typescript": "^5.6.2" 42 | }, 43 | "prettier": { 44 | "proseWrap": "always" 45 | }, 46 | "files": [ 47 | "dist" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | interface GraphQLError { 2 | message: string; 3 | path: ReadonlyArray | undefined; 4 | } 5 | interface GraphQLErrorWithPath extends GraphQLError { 6 | path: ReadonlyArray; 7 | } 8 | 9 | export function toe>(result: { 10 | data?: TData | null | undefined; 11 | errors?: readonly GraphQLError[] | undefined; 12 | }): TData { 13 | const { data, errors } = result; 14 | if (!data) { 15 | if (!errors) { 16 | throw new Error( 17 | "Invalid call to graphql-toe; neither data nor errors were present", 18 | ); 19 | } else { 20 | throw typeof AggregateError === "undefined" 21 | ? errors[0] 22 | : new AggregateError(errors, errors[0].message); 23 | } 24 | } 25 | if (!errors || errors.length === 0) { 26 | return data; 27 | } 28 | return toeObj(data, 0, errors as readonly GraphQLErrorWithPath[]); 29 | } 30 | 31 | function toeObj>( 32 | data: TData, 33 | depth: number, 34 | errors: readonly GraphQLErrorWithPath[], 35 | ): TData { 36 | // TODO: would it be faster to rule out duplicates via a set? 37 | const keys = errors.map((e) => e.path[depth]) as string[]; 38 | const obj = Object.create(null); 39 | for (const key of Object.keys(data)) { 40 | const value = data[key]; 41 | if (keys.includes(key)) { 42 | if (value == null) { 43 | const error = errors.find((e) => e.path[depth] === key); 44 | // This is where the error is! 45 | // obj[key] = value; 46 | Object.defineProperty(obj, key, { 47 | enumerable: true, 48 | get() { 49 | throw error; 50 | }, 51 | }); 52 | } else { 53 | // Guaranteed to have at least one entry 54 | const filteredErrors = errors.filter((e) => e.path[depth] === key); 55 | // Recurse 56 | obj[key] = Array.isArray(value) 57 | ? (toeArr(value, depth + 1, filteredErrors) as any) 58 | : toeObj(value, depth + 1, filteredErrors); 59 | } 60 | } else { 61 | obj[key] = value; 62 | } 63 | } 64 | return obj as TData; 65 | } 66 | 67 | function toeArr( 68 | data: readonly TData[], 69 | depth: number, 70 | errors: readonly GraphQLErrorWithPath[], 71 | ): readonly TData[] { 72 | // TODO: would it be faster to rule out duplicates via a set? 73 | const keys = errors.map((e) => e.path[depth]) as number[]; 74 | const obj = new Array(data.length); 75 | for (let key = 0, l = data.length; key < l; key++) { 76 | const value = data[key]; 77 | if (keys.includes(key)) { 78 | if (value == null) { 79 | const error = errors.find((e) => e.path[depth] === key); 80 | // This is where the error is! 81 | // obj[key] = value; 82 | Object.defineProperty(obj, key, { 83 | enumerable: true, 84 | get() { 85 | throw error; 86 | }, 87 | }); 88 | } else { 89 | // Guaranteed to have at least one entry 90 | const filteredErrors = errors.filter((e) => e.path[depth] === key); 91 | // Recurse 92 | obj[key] = Array.isArray(value) 93 | ? (toeArr(value, depth + 1, filteredErrors) as any) 94 | : toeObj(value, depth + 1, filteredErrors); 95 | } 96 | } else { 97 | obj[key] = value; 98 | } 99 | } 100 | return obj; 101 | } 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es2021"], 5 | "declaration": true, 6 | "rootDir": "src", 7 | "declarationDir": "./dist", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@tsconfig/recommended@^1.0.7": 6 | version "1.0.7" 7 | resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.7.tgz#fdd95fc2c8d643c8b4a8ca45fd68eea248512407" 8 | integrity sha512-xiNMgCuoy4mCL4JTywk9XFs5xpRUcKxtWEcMR6FNMtsgewYTIgIR+nvlP4A4iRCAzRsHMnPhvTRrzp4AGcRTEA== 9 | 10 | "@types/node@^22.5.4": 11 | version "22.5.4" 12 | resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.4.tgz#83f7d1f65bc2ed223bdbf57c7884f1d5a4fa84e8" 13 | integrity sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg== 14 | dependencies: 15 | undici-types "~6.19.2" 16 | 17 | graphql@^16.9.0: 18 | version "16.9.0" 19 | resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" 20 | integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== 21 | 22 | prettier@^3.3.3: 23 | version "3.3.3" 24 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" 25 | integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== 26 | 27 | typescript@^5.6.2: 28 | version "5.6.2" 29 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" 30 | integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== 31 | 32 | undici-types@~6.19.2: 33 | version "6.19.8" 34 | resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" 35 | integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== 36 | --------------------------------------------------------------------------------