├── .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 |
--------------------------------------------------------------------------------