Repository: apollographql/apollo-client

Problem statement:
Auto strip `__typename` does not correctly handle JSON scalars
### Issue Description

#10724 introduced the ability to automatically strip `__typename` fields from `variables` before a request is made to a server. While this works well for the majority of cases, the change is a bit over-eager as it will also strip `__typename` from JSON-related scalars. Since a JSON scalar allows you to pass any arbitrary data, we should be a good citizen and avoid stripping `__typename` in these cases. NOTE: JSON scalars do not have to be named `JSON`. A schema can defined any arbitrarily named scalar type that represents a JSON blob (though `JSON` is the most common name for this type of scalar).

We'll need a mechanism that allows users to opt-in/opt-out of this behavior for these scalars.

There are two approaches we can consider:

1. Opt-out behavior

In this approach, stripping the `__typename` would be considered the default behavior. Users would need to opt-out of this behavior by listing the types where `__typename` should be left alone.

2. Opt-in behavior

On the flipside, we would consider this feature as _opt-in_ which means stripping the `__typename` does not happen automatically. Users would need to opt-in to this behavior by listing the types where the `__typename` should be removed.

There is a good case to be made for either approach and I think both are valid. 

---

There is a scenario that adds a bit of a wrinkle. If you declare an `input` type where one of its fields is a JSON scalar, detecting the boundary at which `__typename` could be left alone cannot solely be done by inspecting the query document. For example, lets say I have this query:

```graphql
mutation UpdateUser($user: UserInput!) {
  updateUser(user: $user) {
    id
  }
}
```

And `UserInput` is defined as:

```graphql
input UserInput {
  id: ID!
  config: JSON
}
```

Here we'd want to strip any `__typename` on the top-level `user` variable, but leave anything inside `config` alone.

We'll need to provide a mechanism for users to specify field paths as well as types to ensure this is handled correctly for all scenarios.

### Link to Reproduction

n/a

### Reproduction Steps

_No response_


Hints:
After some internal discussion, I'm convinced we will not be able to ship this as an opt-out solution because we break backwards compatibility for anyone that relies upon `__typename` as part of a JSON scalar. `__typename` _is_ allowed when used as part of a JSON scalar and people may be using this solution as a workaround for the fact that GraphQL doesn't yet support [input unions](https://github.com/graphql/graphql-wg/blob/main/rfcs/InputUnion.md).

Additionally, there are a few downsides to the approach in https://github.com/apollographql/apollo-client/pull/10724.

1. The change is limited to `HttpLink`, `BatchHttpLink` and `GraphQLWsLink`. If anyone is using a custom terminating link, or the older `WebSocketLink`, this functionality is missed.
2. This functionality needs to be duplicated in every terminating link that should include this functionality. This can lead to increased bundle size.
3. Users that don't need this functionality, either because they strip typenames themselves, or because they don't use data that includes a `__typename` in their variables, suffer from a larger bundle size since the underlying code cannot be tree-shaken away.

---

Instead, the plan is to create a newly exported `ApolloLink` that will provide this functionality. For the sake of this discussion, I'll call it a `StripTypenameLink` (final name TBD).

Shipping a custom link has several advantages that counter the downsides above:

1. We are no longer limited to our built-in terminating links to provide this functionality. You can simply drop a `StripTypenameLink` in your link chain and this will propagate to any terminating link in your app
2. We reduce duplication in our built-in terminating links since this functionality is included in a single place
3. Smaller bundle size for those that don't use the link, since this link can be tree-shaken away.
4. Configuration for this behavior is co-located with the functionality.

### Implementation 

The link would work by inspecting the incoming query document and comparing its variable declarations with the configuration passed to the link. If we match a variable type with a type defined in the config, this tells us we should leave the `__typename` alone (see below for more details on the configuration)

As an example, if I have the following query:

```graphql
mutation MyMutation($json: JSON!, $config: Config!, $input: UserInput!) {
  myMutation(json: $json, config: $config, input: $input)
}

# `JSON` and `Config` are both JSON-based scalars
scalar JSON
scalar Config

# `UserInput` is an input type that could have a nested JSON scalar field
input UserInput {
  id: ID!
  config: Config!
  anotherField: JSON!
}
```

We would expect the following inputs/outputs
```ts
// input
const variables: MyMutationVariables = {
  // `json` is of type `JSON` based on the variable definition
  json: {
    __typename: 'Foo',
    foo: 'bar',
    baz: 'baz',
    nested: { __typename: 'Nested', foo: 'bar' }
  },
  // `config` is of type `Config` based on the variable definition
  config: { __typename: 'Foo', id: '12345' },

  // `input` is of type `UserInput` based on the variable definition
  input: {
    __typename: 'User',
    id: '1',
    config: { __typename: 'UserConfig', field: 'value' },
    anotherField: { __typename: 'Widget', widget: 'foo' }
  }
}

// NOTE: API not final. See below on more detailed understanding of the API
const link = new StripTypenameLink({
  // purposely omit `Config` to strip __typename from its type
  exclude: [
    'JSON',
    ['UserInput', ['config']],
    ['UserInput', ['anotherField']],
  ]
});

// variables sent to the server
const finalVariables = {
  // __typename remains on all nested fields since its type is excluded from getting stripped
  json: {
    __typename: 'Foo',
    foo: 'bar',
    baz: 'baz',
    nested: { __typename: 'Nested', foo: 'bar' }
  },

  // __typename is stripped since its type is not excluded from the stripped types
  config: { id: '12345' },

  // __typename is stripped from top-level `input`, but remains on `config` and `anotherField`
  // since these property paths are excluded from getting stripped
  input: {
    id: '1',
    config: { __typename: 'UserConfig', field: 'value' },
    anotherField: { __typename: 'Widget', widget: 'foo' }
  }
}
```

### API Design

We want to allow for simplicity as much as possible. This link should be easy to drop-in with minimal configuration as possible to reduce friction.

#### Goals/Requirements

* Make it easy to opt-in to stripping `__typename` from everything. Gauging by the number of user-provided solutions in https://github.com/apollographql/apollo-feature-requests/issues/6 that don't handle JSON scalars in any meaningful way, this feels like the most common use case.
* Allow a user to define types that should leave `__typename` in-tact. These can be detected from the variable definitions in the query document NOTE: We can't assume all JSON scalars are named `JSON`, since a schema could define a more semantically meaningful name (e.g. `Config`), hence why we need this to be configurable.
* Allow a user to define a property path that should leave `__typename` in-tact. If a JSON scalar is a key in a deeply nested input object, simply trying to detect the input type is not enough (see the description in this issue for an example of this problem).

I propose the following behaviors:

1. Optimize for the common case, which is to strip `__typename` from everything. JSON scalars, while useful, are typically seen as an anti-pattern since they discourage strong types. 

```ts
// final name/import TBD
import { StripTypenameLink } from '@apollo/client/link/stripTypename';

// pass nothing to strip everything by default
const stripTypenameLink = new StripTypenameLink();
```

2. Allow users to _exclude_ types that should not adhere to this behavior, instead of forcing the user to include types that should adhere to this behavior. Explicit types should be the norm. JSON scalars should be the exception. By optimizing for the majority case, this should allow for a more minimal configuration.

```ts
import { StripTypenameLink } from '@apollo/link/stripTypename';

const link = new StripTypenameLink({
  // simple types
  exclude: ['JSON', 'Config', 'SomeOtherJSONScalarType'],

  // types with paths
  exclude: [
    // Keep `__typename` for the `config` property of a `UserInput` type, but strip all others
    ['UserInput', ['config']],

    // Keep `__typename` on both `anotherConfig` and `config` of `UserInput` since `UserInput` is defined more than once
    ['UserInput', ['anotherConfig']],

    // Keep __typename for a deeply nested field
    ['DashboardInput', ['deeply', 'nested', 'field']]
  ],

  // can combine simple types and types with property paths
  exclude: ['JSON', ['UserInput', ['config']], ['UserInput', ['anotherConfig']]]
});
```

> NOTE: I'm not in love with this API as the array nesting can get hard to read quickly. This should offer a good starting point for discussion at minimum.

#### Alternate API ideas for excluding types/property paths

- Split types/paths config between 2 properties
```ts
new StripTypenameLink({
  excludeTypes: ['JSON', 'Config'],
  excludePaths: {
    UserInput: [['config'], ['anotherConfig']],
    DashboardInput: ['widgets', 'config']
  }
});
```

- Use named `path` and `paths` options for the second array item. These are mutually exclusive
```ts
new StripTypenameLink({
  exclude: [
    // single path on a type
    ['UserInput', { path: ['config'] }
  
    // multiple paths on a type
    ['UserInput', { paths: [['config', ['widgets', 'config']] }]
  ]
});
```

- Use `keyArgs`-style config. Use a nested array to denote a nested field
```ts
new StripTypenameLink({
  exclude: [
    // Keep __typename on all fields inside JSON types
    'JSON',

    // `config` and `widgets` are separate properties on the same object. `widgetConfig` is nested inside `widgets`
    ['UserInput', ['config', 'widgets', ['widgetConfig']]]
  ]
});
```

- String-delimited paths
```ts
new StripTypenameLink({
  exclude: [
    // single path
    ['UserInput', ['config']],

    // multiple path
    ['DashboardInput', ['deeply.nested.field', 'another.field']]
  ]
});
```

Any combination of these APIs are valid. Suggestions welcome for alternatives!
What about
```js
new StripTypenameLink({
  exclude: {
    UserInput: { config: true },
    DashboardInput: {
      deeply: { nested: { field: true }},
      another: { field: true }
    }
  }
});
```
?

Or, if we generally think about a Link that can be used to transform outgoing data:
```js
new TransformDataLink([
  keepTypename('UserInput', 'config'),
  keepTypename('DashboardInput', 'deeply', 'nested', 'field'),
  keepTypename('DashboardInput', 'another', 'field'),
  stripTypename(),
])
```

If users had a lot of fields, they could abstract over `strip` to make that work best for them. (Note: When using libraries, people like to forget that abstracting is a thing, so we would need to show at least one docs example, that yes, JavaScript is a programming language and you can write your own abstraction)
@phryneas those are interesting ideas!

> ```ts
> new StripTypenameLink({
>   exclude: {
>     UserInput: { config: true },
>     DashboardInput: {
>       deeply: { nested: { field: true }},
>       another: { field: true }
>     }
>   }
> });
> ```

I love the readability of this example. It's much easier to parse than something like this: `['JSON', ['UserInput', ['config']], ['UserInput', ['anotherConfig']]]`

The only thing I don't love about this is the fact that `true` is the only value that makes sense here, so at a glance, people might assume there is an equivalent `false`.

Perhaps we could just make the leaf nodes an array in that case?

```ts
new StripTypenameLink({
  exclude: {
    UserInput: ['config'],
    DashboardInput: {
      deeply: { nested: ['field'] },
      another: ['field']
    }
  }
});
```

This gets rid of the `true`, but could get weird in other cases, where yours would handle this nicely:

```ts
new StripTypenameLink({
  exclude: {
    DashboardInput: {
      // How I envision a leaf field + another nested field at the same level
      deeply: ['another', { nested: ['field'] }],
    }
  }
});
```

---

> ```ts
> new TransformDataLink([
>   keepTypename('UserInput', 'config'),
>   keepTypename('DashboardInput', 'deeply', 'nested', 'field'),
>   keepTypename('DashboardInput', 'another', 'field'),
>   stripTypename(),
> ])
> ```

This is an interesting idea, though in some ways it feels similar to `ApolloLink` already: `ApolloLink.from([link1, link2])`. In this case, what "data" would you see this operating on? Would this be strictly outgoing data (query, variables, context, etc?)? Would you limit this to just a subset of the `operation`?
Huh, that's an interesting edge case.

My original idea would also not be able to handle this I believe 🤔

```js
new StripTypenameLink({
  exclude: {
    UserInput: { config: true },
    DashboardInput: {
      // how to keep `__typename` here?
      deeply: { nested: { field: true } },
      another: { field: true },
    },
  },
});
```

Another version that could do this, and is a bit clearer, although it is more code, could look like this:

```js
import { StripTypenameLink, keepTypename } from "...";

new StripTypenameLink({
  exclude: {
    UserInput: {
      config: {
        [keepTypename]: true,
      },
    },
    DashboardInput: {
      // this works now
      [keepTypename]: true,
      deeply: {
        nested: {
          field: {
            [keepTypename]: true,
          },
        },
      },
      another: {
        field: {
          [keepTypename]: true,
        },
      },
    },
  },
});
```

And then we could get it shorter again, by mixing both approaches and allowing a shortnotation in the form of `fieldName: keepTypename`:

```js
import { StripTypenameLink, keepTypename } from "...";

new StripTypenameLink({
  exclude: {
    // keepTypename makes more sense than `true` here
    UserInput: { config: keepTypename },
    DashboardInput: {
      // now this works
      [keepTypename]: true,
      deeply: { nested: { field: keepTypename } },
      another: { field: keepTypename },
    },
  },
});
```


> Would you limit this to just a subset of the operation?

Honestly, I have no idea - I just had the thought that we could explore into that direction and didn't want to immediately dismiss it, so I jotted it down. Your interpretations here are as good as mine ^^


I have to admit that generally, I'm not a big fan of the "nested array" variants - while they are shorter, they are very hard to explain, and they might be very easy to get wrong.
> I have to admit that generally, I'm not a big fan of the "nested array" variants - while they are shorter, they are very hard to explain, and they might be very easy to get wrong.

I would agree. Its fine for small things, but can get very difficult to read the more nested arrays you have. I do like the idea of an object-like syntax for readability, we just have to settle on the way to denote the leaves of the field paths.
Another potential option:

```ts
new StripTypenameLink({
  excludeTypes: ['JSON'],
  exclude: {
    UserInput: `
      config {
        widgets { config }
      }
    `
  }
});
```
Another option:

```ts
new StripTypenameLink({
  exclude: [
    'JSON',
    'UserInput.config.widgets.config',
    'UserInput.config',
  ]
})
```

We could think about supporting regex:

```ts
new StripTypenameLink({
  exclude: [
    'JSON',
    /.*Input/
  ]
})
```

Patch:
diff --git a/config/entryPoints.js b/config/entryPoints.js
--- a/config/entryPoints.js
+++ b/config/entryPoints.js
@@ -11,6 +11,7 @@ const entryPoints = [
   { dirs: ['link', 'http'] },
   { dirs: ['link', 'persisted-queries'] },
   { dirs: ['link', 'retry'] },
+  { dirs: ['link', 'remove-typename'] },
   { dirs: ['link', 'schema'] },
   { dirs: ['link', 'subscriptions'] },
   { dirs: ['link', 'utils'] },
diff --git a/src/link/http/selectHttpOptionsAndBody.ts b/src/link/http/selectHttpOptionsAndBody.ts
--- a/src/link/http/selectHttpOptionsAndBody.ts
+++ b/src/link/http/selectHttpOptionsAndBody.ts
@@ -1,6 +1,5 @@
 import type { ASTNode} from 'graphql';
 import { print } from 'graphql';
-import { stripTypename } from '../../utilities';
 
 import type { Operation } from '../core';
 
@@ -181,7 +180,7 @@ export function selectHttpOptionsAndBodyInternal(
 
   //The body depends on the http options
   const { operationName, extensions, variables, query } = operation;
-  const body: Body = { operationName, variables: stripTypename(variables) };
+  const body: Body = { operationName, variables };
 
   if (http.includeExtensions) (body as any).extensions = extensions;
 
diff --git a/src/link/remove-typename/index.ts b/src/link/remove-typename/index.ts
new file mode 100644
--- /dev/null
+++ b/src/link/remove-typename/index.ts
@@ -0,0 +1,5 @@
+export {
+  removeTypenameFromVariables,
+  KEEP,
+  RemoveTypenameFromVariablesOptions,
+} from './removeTypenameFromVariables';
diff --git a/src/link/remove-typename/removeTypenameFromVariables.ts b/src/link/remove-typename/removeTypenameFromVariables.ts
new file mode 100644
--- /dev/null
+++ b/src/link/remove-typename/removeTypenameFromVariables.ts
@@ -0,0 +1,105 @@
+import { Trie } from '@wry/trie';
+import { wrap } from 'optimism';
+import type { DocumentNode, TypeNode } from 'graphql';
+import { Kind, visit } from 'graphql';
+import { ApolloLink } from '../core';
+import { canUseWeakMap, stripTypename } from '../../utilities';
+
+export const KEEP = '__KEEP';
+
+interface KeepTypenameConfig {
+  [key: string]: typeof KEEP | KeepTypenameConfig;
+}
+
+export interface RemoveTypenameFromVariablesOptions {
+  except?: KeepTypenameConfig;
+}
+
+export function removeTypenameFromVariables(
+  options: RemoveTypenameFromVariablesOptions = Object.create(null)
+) {
+  const { except } = options;
+
+  const trie = new Trie<typeof stripTypename.BREAK>(
+    canUseWeakMap,
+    () => stripTypename.BREAK
+  );
+
+  if (except) {
+    // Use `lookupArray` to store the path in the `trie` ahead of time. We use
+    // `peekArray` when actually checking if a path is configured in the trie
+    // to avoid creating additional lookup paths in the trie.
+    collectPaths(except, (path) => trie.lookupArray(path));
+  }
+
+  return new ApolloLink((operation, forward) => {
+    const { query, variables } = operation;
+
+    if (!variables) {
+      return forward(operation);
+    }
+
+    if (!except) {
+      return forward({ ...operation, variables: stripTypename(variables) });
+    }
+
+    const variableDefinitions = getVariableDefinitions(query);
+
+    return forward({
+      ...operation,
+      variables: stripTypename(variables, {
+        keep: (variablePath) => {
+          const typename = variableDefinitions[variablePath[0]];
+
+          // The path configurations do not include array indexes, so we
+          // omit them when checking the `trie` for a configured path
+          const withoutArrayIndexes = variablePath.filter(
+            (segment) => typeof segment === 'string'
+          );
+
+          // Our path configurations use the typename as the root so we need to
+          // replace the first segment in the variable path with the typename
+          // instead of the top-level variable name.
+          return trie.peekArray([typename, ...withoutArrayIndexes.slice(1)]);
+        },
+      }),
+    });
+  });
+}
+
+const getVariableDefinitions = wrap((document: DocumentNode) => {
+  const definitions: Record<string, string> = {};
+
+  visit(document, {
+    VariableDefinition(node) {
+      definitions[node.variable.name.value] = unwrapType(node.type);
+    },
+  });
+
+  return definitions;
+});
+
+function unwrapType(node: TypeNode): string {
+  switch (node.kind) {
+    case Kind.NON_NULL_TYPE:
+      return unwrapType(node.type);
+    case Kind.LIST_TYPE:
+      return unwrapType(node.type);
+    case Kind.NAMED_TYPE:
+      return node.name.value;
+  }
+}
+
+function collectPaths(
+  config: KeepTypenameConfig,
+  register: (path: string[]) => void,
+  path: string[] = []
+) {
+  Object.entries(config).forEach(([key, value]) => {
+    if (value === KEEP) {
+      return register([...path, key]);
+    }
+
+    collectPaths(value, register, path.concat(key));
+  });
+}
diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts
--- a/src/link/subscriptions/index.ts
+++ b/src/link/subscriptions/index.ts
@@ -33,7 +33,7 @@ import type { Client } from "graphql-ws";
 
 import type { Operation, FetchResult } from "../core";
 import { ApolloLink } from "../core";
-import { isNonNullObject, stripTypename, Observable } from "../../utilities";
+import { isNonNullObject, Observable } from "../../utilities";
 import { ApolloError } from "../../errors";
 
 // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event
@@ -54,11 +54,7 @@ export class GraphQLWsLink extends ApolloLink {
   public request(operation: Operation): Observable<FetchResult> {
     return new Observable((observer) => {
       return this.client.subscribe<FetchResult>(
-        { 
-          ...operation,
-          query: print(operation.query),
-          variables: stripTypename(operation.variables) 
-        },
+        { ...operation, query: print(operation.query) },
         {
           next: observer.next.bind(observer),
           complete: observer.complete.bind(observer),
diff --git a/src/utilities/common/omitDeep.ts b/src/utilities/common/omitDeep.ts
--- a/src/utilities/common/omitDeep.ts
+++ b/src/utilities/common/omitDeep.ts
@@ -1,14 +1,28 @@
 import type { DeepOmit } from '../types/DeepOmit';
 import { isPlainObject } from './objects';
 
-export function omitDeep<T, K extends string>(value: T, key: K) {
-  return __omitDeep(value, key);
+const BREAK: unique symbol = Symbol('BREAK');
+
+export interface OmitDeepOptions {
+  keep?: (path: (string | number)[]) => boolean | typeof BREAK | undefined;
+}
+
+export function omitDeep<T, K extends string>(
+  value: T,
+  key: K,
+  options: OmitDeepOptions = Object.create(null)
+) {
+  return __omitDeep(value, key, options);
 }
 
+omitDeep.BREAK = BREAK;
+
 function __omitDeep<T, K extends string>(
   value: T,
   key: K,
-  known = new Map<any, any>()
+  options: OmitDeepOptions,
+  known = new Map<any, any>(),
+  path = [] as (string | number)[]
 ): DeepOmit<T, K> {
   if (known.has(value)) {
     return known.get(value);
@@ -21,7 +35,11 @@ function __omitDeep<T, K extends string>(
     known.set(value, array);
 
     value.forEach((value, index) => {
-      const result = __omitDeep(value, key, known);
+      const objectPath = path.concat(index);
+      const result =
+        options.keep?.(objectPath) === BREAK
+          ? value
+          : __omitDeep(value, key, options, known, objectPath);
       modified ||= result !== value;
 
       array[index] = result;
@@ -35,14 +53,22 @@ function __omitDeep<T, K extends string>(
     known.set(value, obj);
 
     Object.keys(value).forEach((k) => {
-      if (k === key) {
-        modified = true;
-      } else {
-        const result = __omitDeep(value[k], key, known);
-        modified ||= result !== value[k];
+      const objectPath = path.concat(k);
+      const keep = options.keep?.(objectPath);
 
-        obj[k] = result;
+      if (k === key && keep !== true) {
+        modified = true;
+        return;
       }
+
+      const result =
+        keep === BREAK
+          ? value[k]
+          : __omitDeep(value[k], key, options, known, objectPath);
+
+      modified ||= result !== value[k];
+
+      obj[k] = result;
     });
 
     if (modified) {
diff --git a/src/utilities/common/stripTypename.ts b/src/utilities/common/stripTypename.ts
--- a/src/utilities/common/stripTypename.ts
+++ b/src/utilities/common/stripTypename.ts
@@ -1,5 +1,12 @@
+import type { OmitDeepOptions } from './omitDeep';
 import { omitDeep } from './omitDeep';
 
-export function stripTypename<T>(value: T) {
-  return omitDeep(value, '__typename');
+interface StripTypenameOptions {
+  keep?: OmitDeepOptions['keep'];
 }
+
+export function stripTypename<T>(value: T, options?: StripTypenameOptions) {
+  return omitDeep(value, '__typename', options);
+}
+
+stripTypename.BREAK = omitDeep.BREAK;
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -106,7 +106,7 @@ export * from './common/mergeOptions';
 export * from './common/responseIterator';
 export * from './common/incrementalResult';
 
-export { omitDeep } from './common/omitDeep';
+export { omitDeep, OmitDeepOptions } from './common/omitDeep';
 export { stripTypename } from './common/stripTypename';
 
 export * from './types/IsStrictlyAny';


Test patch:
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap
--- a/src/__tests__/__snapshots__/exports.ts.snap
+++ b/src/__tests__/__snapshots__/exports.ts.snap
@@ -206,6 +206,13 @@ Array [
 ]
 `;
 
+exports[`exports of public entry points @apollo/client/link/remove-typename 1`] = `
+Array [
+  "KEEP",
+  "removeTypenameFromVariables",
+]
+`;
+
 exports[`exports of public entry points @apollo/client/link/retry 1`] = `
 Array [
   "RetryLink",
diff --git a/src/__tests__/exports.ts b/src/__tests__/exports.ts
--- a/src/__tests__/exports.ts
+++ b/src/__tests__/exports.ts
@@ -15,6 +15,7 @@ import * as linkError from "../link/error";
 import * as linkHTTP from "../link/http";
 import * as linkPersistedQueries from "../link/persisted-queries";
 import * as linkRetry from "../link/retry";
+import * as linkRemoveTypename from "../link/remove-typename";
 import * as linkSchema from "../link/schema";
 import * as linkSubscriptions from "../link/subscriptions";
 import * as linkUtils from "../link/utils";
@@ -56,6 +57,7 @@ describe('exports of public entry points', () => {
   check("@apollo/client/link/error", linkError);
   check("@apollo/client/link/http", linkHTTP);
   check("@apollo/client/link/persisted-queries", linkPersistedQueries);
+  check("@apollo/client/link/remove-typename", linkRemoveTypename);
   check("@apollo/client/link/retry", linkRetry);
   check("@apollo/client/link/schema", linkSchema);
   check("@apollo/client/link/subscriptions", linkSubscriptions);
diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts
--- a/src/link/http/__tests__/HttpLink.ts
+++ b/src/link/http/__tests__/HttpLink.ts
@@ -1111,262 +1111,6 @@ describe('HttpLink', () => {
         new Error('HttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or set the `transformOptions.removeClientFields` option to `true`.')
       );
     });
-
-    it('strips __typename from object argument when sending a mutation', async () => {
-      fetchMock.mock('https://example.com/graphql', {
-        status: 200,
-        body: JSON.stringify({
-          data: {
-            __typename: 'Mutation',
-            updateTodo: { 
-              __typename: 'Todo',
-              id: 1,
-              name: 'Take out trash',
-              completed: true 
-            }
-          }
-        }),
-        headers: { 'content-type': 'application/json' }
-      });
-
-      const query = gql`
-        mutation UpdateTodo($todo: TodoInput!) {
-          updateTodo(todo: $todo) {
-            id
-            name
-            completed
-          }
-        }
-      `;
-
-      const link = createHttpLink({ uri: 'https://example.com/graphql' });
-
-      const todo = { 
-        __typename: 'Todo',
-        id: 1,
-        name: 'Take out trash',
-        completed: true,
-      }
-
-      await new Promise((resolve, reject) => {
-        execute(link, { query, variables: { todo } }).subscribe({
-          next: resolve,
-          error: reject
-        });
-      });
-
-      const [, options] = fetchMock.lastCall()!;
-      const { body } = options!
-
-      expect(JSON.parse(body!.toString())).toEqual({
-        operationName: 'UpdateTodo',
-        query: print(query),
-        variables: {
-          todo: {
-            id: 1,
-            name: 'Take out trash',
-            completed: true,
-          }
-        }
-      });
-    });
-
-    it('strips __typename from array argument when sending a mutation', async () => {
-      fetchMock.mock('https://example.com/graphql', {
-        status: 200,
-        body: JSON.stringify({
-          data: {
-            __typename: 'Mutation',
-            updateTodos: [
-              { 
-                __typename: 'Todo',
-                id: 1,
-                name: 'Take out trash',
-                completed: true 
-              },
-              { 
-                __typename: 'Todo',
-                id: 2,
-                name: 'Clean room',
-                completed: true 
-              },
-            ]
-          }
-        }),
-        headers: { 'content-type': 'application/json' }
-      });
-
-      const query = gql`
-        mutation UpdateTodos($todos: [TodoInput!]!) {
-          updateTodos(todos: $todos) {
-            id
-            name
-            completed
-          }
-        }
-      `;
-
-      const link = createHttpLink({ uri: 'https://example.com/graphql' });
-
-      const todos = [
-        { 
-          __typename: 'Todo',
-          id: 1,
-          name: 'Take out trash',
-          completed: true,
-        },
-        { 
-          __typename: 'Todo',
-          id: 2,
-          name: 'Clean room',
-          completed: true,
-        },
-      ];
-
-      await new Promise((resolve, reject) => {
-        execute(link, { query, variables: { todos } }).subscribe({
-          next: resolve,
-          error: reject
-        });
-      });
-
-      const [, options] = fetchMock.lastCall()!;
-      const { body } = options!
-
-      expect(JSON.parse(body!.toString())).toEqual({
-        operationName: 'UpdateTodos',
-        query: print(query),
-        variables: {
-          todos: [
-            {
-              id: 1,
-              name: 'Take out trash',
-              completed: true,
-            },
-            {
-              id: 2,
-              name: 'Clean room',
-              completed: true,
-            },
-          ]
-        }
-      });
-    });
-
-    it('strips __typename from mixed argument when sending a mutation', async () => {
-      fetchMock.mock('https://example.com/graphql', {
-        status: 200,
-        body: JSON.stringify({
-          data: {
-            __typename: 'Mutation',
-            updateProfile: {
-              __typename: 'Profile',
-              id: 1,
-            },
-          }
-        }),
-        headers: { 'content-type': 'application/json' }
-      });
-
-      const query = gql`
-        mutation UpdateProfile($profile: ProfileInput!) {
-          updateProfile(profile: $profile) {
-            id
-          }
-        }
-      `;
-
-      const link = createHttpLink({ uri: 'https://example.com/graphql' });
-
-      const profile = { 
-        __typename: 'Profile',
-        id: 1,
-        interests: [
-          { __typename: 'Interest', name: 'Hiking' },
-          { __typename: 'Interest', name: 'Nature' }
-        ],
-        avatar: {
-          __typename: 'Avatar',
-          url: 'https://example.com/avatar.jpg',
-        }
-      };
-
-      await new Promise((resolve, reject) => {
-        execute(link, { query, variables: { profile } }).subscribe({
-          next: resolve,
-          error: reject
-        });
-      });
-
-      const [, options] = fetchMock.lastCall()!;
-      const { body } = options!
-
-      expect(JSON.parse(body!.toString())).toEqual({
-        operationName: 'UpdateProfile',
-        query: print(query),
-        variables: {
-          profile: {
-            id: 1,
-            interests: [
-              { name: 'Hiking' },
-              { name: 'Nature' }
-            ],
-            avatar: {
-              url: 'https://example.com/avatar.jpg',
-            },
-          },
-        }
-      });
-    });
-  });
-
-  it('strips __typename when sending a query', async () => {
-    fetchMock.mock('https://example.com/graphql', {
-      status: 200,
-      body: JSON.stringify({
-        data: {
-          __typename: 'Query',
-          searchTodos: []
-        }
-      }),
-      headers: { 'content-type': 'application/json' }
-    });
-
-    const query = gql`
-      query SearchTodos($filter: TodoFilter!) {
-        searchTodos(filter: $filter) {
-          id
-          name
-        }
-      }
-    `;
-
-    const link = createHttpLink({ uri: 'https://example.com/graphql' });
-
-    const filter = { 
-      __typename: 'Filter',
-      completed: true,
-    };
-
-    await new Promise((resolve, reject) => {
-      execute(link, { query, variables: { filter } }).subscribe({
-        next: resolve,
-        error: reject
-      });
-    });
-
-    const [, options] = fetchMock.lastCall()!;
-    const { body } = options!
-
-    expect(JSON.parse(body!.toString())).toEqual({
-      operationName: 'SearchTodos',
-      query: print(query),
-      variables: {
-        filter: {
-          completed: true,
-        },
-      },
-    });
   });
 
   describe('Dev warnings', () => {
diff --git a/src/link/http/__tests__/selectHttpOptionsAndBody.ts b/src/link/http/__tests__/selectHttpOptionsAndBody.ts
--- a/src/link/http/__tests__/selectHttpOptionsAndBody.ts
+++ b/src/link/http/__tests__/selectHttpOptionsAndBody.ts
@@ -104,25 +104,4 @@ describe('selectHttpOptionsAndBody', () => {
 
     expect(body.query).toBe('query SampleQuery{stub{id}}');
   });
-
-  it('strips __typename from variables', () => {
-    const operation = createOperation(
-      {}, 
-      { 
-        query,
-        variables: { 
-          __typename: 'Test',
-          nested: { __typename: 'Nested', foo: 'bar' },
-          array: [{ __typename: 'Item', baz: 'foo' }]
-        }, 
-      }
-    );
-
-    const { body } = selectHttpOptionsAndBody(operation, {});
-
-    expect(body.variables).toEqual({ 
-      nested: { foo: 'bar' },
-      array: [{ baz: 'foo' }],
-    });
-  })
 });
diff --git a/src/link/remove-typename/__tests__/removeTypenameFromVariables.ts b/src/link/remove-typename/__tests__/removeTypenameFromVariables.ts
new file mode 100644
--- /dev/null
+++ b/src/link/remove-typename/__tests__/removeTypenameFromVariables.ts
@@ -0,0 +1,417 @@
+import {
+  KEEP,
+  removeTypenameFromVariables,
+} from '../removeTypenameFromVariables';
+import { ApolloLink, Operation } from '../../core';
+import { Observable, gql } from '../../../core';
+import { createOperation, toPromise } from '../../utils';
+
+type PartialOperation = Partial<Pick<Operation, 'variables'>> &
+  Pick<Operation, 'query'>;
+
+// Since this link modifies the `operation` and we only care to test against
+// the changed operation, we use a custom `execute` helper here instead of the
+// version exported by the `core` module, which expects a well-formed response.
+async function execute(link: ApolloLink, operation: PartialOperation) {
+  function forward(operation: Operation) {
+    // use the `data` key to satisfy the TypeScript types required by
+    // `forward`'s' return value
+    return Observable.of({ data: operation });
+  }
+
+  const { data } = await toPromise(
+    link.request(createOperation({}, operation), forward)!
+  );
+
+  return data as Operation;
+}
+
+test('strips all __typename keys by default', async () => {
+  const query = gql`
+    query Test($foo: FooInput!, $bar: BarInput!) {
+      someField(foo: $foo, bar: $bar)
+    }
+  `;
+
+  const link = removeTypenameFromVariables();
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        foo: true,
+        bar: 'Bar',
+        baz: { __typename: 'Baz', baz: true },
+        qux: [{ __typename: 'Qux', qux: 0 }],
+      },
+      bar: [{ __typename: 'Bar', bar: true }],
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      foo: true,
+      bar: 'Bar',
+      baz: { baz: true },
+      qux: [{ qux: 0 }],
+    },
+    bar: [{ bar: true }],
+  });
+});
+
+test('does nothing when no variables are passed', async () => {
+  const query = gql`
+    query Test {
+      foo {
+        bar
+      }
+    }
+  `;
+
+  const link = removeTypenameFromVariables();
+
+  const operation = { query };
+  const resultOperation = await execute(link, operation);
+
+  expect(resultOperation).toBe(operation);
+});
+
+test('does nothing when no variables are passed even if variables are declared in the document', async () => {
+  const query = gql`
+    query Test($unused: Boolean) {
+      foo {
+        bar
+      }
+    }
+  `;
+
+  const link = removeTypenameFromVariables();
+
+  const operation = { query };
+  const resultOperation = await execute(link, operation);
+
+  expect(resultOperation).toBe(operation);
+});
+
+test('keeps __typename for variables with types defined by `except`', async () => {
+  const query = gql`
+    query Test($foo: JSON, $bar: BarInput) {
+      someField(foo: $foo, bar: $bar)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      JSON: KEEP,
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        foo: true,
+        baz: { __typename: 'Baz', baz: true },
+      },
+      bar: { __typename: 'Bar', bar: true },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      __typename: 'Foo',
+      foo: true,
+      baz: { __typename: 'Baz', baz: true },
+    },
+    bar: { bar: true },
+  });
+});
+
+test('keeps __typename in all variables with types configured with `except`', async () => {
+  const query = gql`
+    query Test($foo: JSON, $bar: Config, $baz: BazInput) {
+      someField(foo: $foo, bar: $bar, baz: $baz)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      JSON: KEEP,
+      Config: KEEP,
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: { __typename: 'Foo', foo: true },
+      bar: { __typename: 'Bar', bar: true },
+      baz: { __typename: 'Baz', baz: true },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: { __typename: 'Foo', foo: true },
+    bar: { __typename: 'Bar', bar: true },
+    baz: { baz: true },
+  });
+});
+
+test('handles variable declarations declared as non null and list types', async () => {
+  const query = gql`
+    query Test($foo: JSON!, $bar: [JSON], $baz: [JSON!]!, $qux: QuxInput!) {
+      someField(foo: $foo, bar: $bar, baz: $baz)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      JSON: KEEP,
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: { __typename: 'Foo', foo: true },
+      bar: [
+        { __typename: 'Bar', bar: true, baz: { __typename: 'Baz', baz: true } },
+      ],
+      baz: [
+        { __typename: 'Baz', baz: true },
+        { __typename: 'Baz', baz: true },
+      ],
+      qux: { __typename: 'Qux', qux: true },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: { __typename: 'Foo', foo: true },
+    bar: [
+      { __typename: 'Bar', bar: true, baz: { __typename: 'Baz', baz: true } },
+    ],
+    baz: [
+      { __typename: 'Baz', baz: true },
+      { __typename: 'Baz', baz: true },
+    ],
+    qux: { qux: true },
+  });
+});
+
+test('keeps __typename at configured fields under input object types', async () => {
+  const query = gql`
+    query Test($foo: FooInput) {
+      someField(foo: $foo)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      FooInput: {
+        bar: KEEP,
+        baz: KEEP,
+      },
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        aa: true,
+        bar: {
+          __typename: 'Bar',
+          bb: true,
+        },
+        baz: {
+          __typename: 'Baz',
+          cc: true,
+        },
+        qux: {
+          __typename: 'Qux',
+          dd: true,
+        },
+      },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      aa: true,
+      bar: {
+        __typename: 'Bar',
+        bb: true,
+      },
+      baz: {
+        __typename: 'Baz',
+        cc: true,
+      },
+      qux: {
+        dd: true,
+      },
+    },
+  });
+});
+
+test('keeps __typename at a deeply nested field', async () => {
+  const query = gql`
+    query Test($foo: FooInput) {
+      someField(foo: $foo)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      FooInput: {
+        bar: {
+          baz: {
+            qux: KEEP,
+          },
+        },
+      },
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        bar: {
+          __typename: 'Bar',
+          baz: {
+            __typename: 'Baz',
+            qux: {
+              __typename: 'Qux',
+              quux: true,
+            },
+          },
+        },
+      },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      bar: {
+        baz: {
+          qux: {
+            __typename: 'Qux',
+            quux: true,
+          },
+        },
+      },
+    },
+  });
+});
+
+test('handles configured fields varying nesting levels', async () => {
+  const query = gql`
+    query Test($foo: FooInput) {
+      someField(foo: $foo)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      FooInput: {
+        bar: KEEP,
+        baz: {
+          qux: KEEP,
+        },
+      },
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        bar: {
+          __typename: 'Bar',
+          aa: true,
+        },
+        baz: {
+          __typename: 'Baz',
+          qux: {
+            __typename: 'Qux',
+            quux: true,
+          },
+        },
+      },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      bar: {
+        __typename: 'Bar',
+        aa: true,
+      },
+      baz: {
+        qux: {
+          __typename: 'Qux',
+          quux: true,
+        },
+      },
+    },
+  });
+});
+
+test('handles multiple configured types with fields', async () => {
+  const query = gql`
+    query Test($foo: FooInput, $baz: BazInput) {
+      someField(foo: $foo, baz: $baz)
+    }
+  `;
+
+  const link = removeTypenameFromVariables({
+    except: {
+      FooInput: {
+        bar: KEEP,
+      },
+      BazInput: {
+        qux: KEEP,
+      },
+    },
+  });
+
+  const { variables } = await execute(link, {
+    query,
+    variables: {
+      foo: {
+        __typename: 'Foo',
+        bar: {
+          __typename: 'Bar',
+          aa: true,
+        },
+      },
+      baz: {
+        __typename: 'Bar',
+        qux: {
+          __typename: 'Qux',
+          bb: true,
+        },
+      },
+    },
+  });
+
+  expect(variables).toStrictEqual({
+    foo: {
+      bar: {
+        __typename: 'Bar',
+        aa: true,
+      },
+    },
+    baz: {
+      qux: {
+        __typename: 'Qux',
+        bb: true,
+      },
+    },
+  });
+});
diff --git a/src/utilities/common/__tests__/omitDeep.ts b/src/utilities/common/__tests__/omitDeep.ts
--- a/src/utilities/common/__tests__/omitDeep.ts
+++ b/src/utilities/common/__tests__/omitDeep.ts
@@ -1,3 +1,4 @@
+import equal from '@wry/equality';
 import { omitDeep } from '../omitDeep';
 
 test('omits the key from a shallow object', () => {
@@ -135,3 +136,114 @@ test('only considers plain objects and ignores class instances when omitting pro
   expect(modifiedThing).toBe(thing);
   expect(modifiedThing).toHaveProperty('omit', false);
 });
+
+test('allows paths to be conditionally kept with the `keep` option by returning `true`', () => {
+  const original = {
+    omit: true,
+    foo: { omit: false, bar: 'bar' },
+    omitFirst: [
+      { omit: true, foo: 'bar' },
+      { omit: false, foo: 'bar' },
+    ],
+  };
+
+  const result = omitDeep(original, 'omit', {
+    keep: (path) => {
+      return (
+        equal(path, ['foo', 'omit']) || equal(path, ['omitFirst', 1, 'omit'])
+      );
+    },
+  });
+
+  expect(result).toEqual({
+    foo: { omit: false, bar: 'bar' },
+    omitFirst: [{ foo: 'bar' }, { omit: false, foo: 'bar' }],
+  });
+});
+
+test('allows path traversal to be skipped with the `keep` option by returning `BREAK`', () => {
+  const original = {
+    omit: true,
+    foo: {
+      omit: false,
+      bar: 'bar',
+      baz: {
+        foo: 'bar',
+        omit: false,
+      },
+    },
+    keepAll: [
+      { omit: false, foo: 'bar' },
+      { omit: false, foo: 'bar' },
+    ],
+    keepOne: [
+      { omit: false, nested: { omit: false, foo: 'bar' } },
+      { omit: true, nested: { omit: true, foo: 'bar' } },
+    ],
+  };
+
+  const result = omitDeep(original, 'omit', {
+    keep: (path) => {
+      if (
+        equal(path, ['foo']) ||
+        equal(path, ['keepAll']) ||
+        equal(path, ['keepOne', 0])
+      ) {
+        return omitDeep.BREAK;
+      }
+    },
+  });
+
+  expect(result).toEqual({
+    foo: { omit: false, bar: 'bar', baz: { foo: 'bar', omit: false } },
+    keepAll: [
+      { omit: false, foo: 'bar' },
+      { omit: false, foo: 'bar' },
+    ],
+    keepOne: [
+      { omit: false, nested: { omit: false, foo: 'bar' } },
+      { nested: { foo: 'bar' } },
+    ],
+  });
+
+  expect(result.foo).toBe(original.foo);
+  expect(result.keepAll).toBe(original.keepAll);
+  expect(result.keepOne[0]).toBe(original.keepOne[0]);
+});
+
+test('can mix and match `keep` with `true `and `BREAK`', () => {
+  const original = {
+    omit: true,
+    foo: {
+      omit: false,
+      bar: 'bar',
+      baz: {
+        foo: 'bar',
+        omit: false,
+      },
+    },
+    omitFirst: [
+      { omit: false, foo: 'bar' },
+      { omit: true, foo: 'bar' },
+    ],
+  };
+
+  const result = omitDeep(original, 'omit', {
+    keep: (path) => {
+      if (equal(path, ['foo'])) {
+        return omitDeep.BREAK;
+      }
+
+      if (equal(path, ['omitFirst', 0, 'omit'])) {
+        return true;
+      }
+    },
+  });
+
+  expect(result).toEqual({
+    foo: { omit: false, bar: 'bar', baz: { foo: 'bar', omit: false } },
+    omitFirst: [{ omit: false, foo: 'bar' }, { foo: 'bar' }],
+  });
+
+  expect(result.foo).toBe(original.foo);
+});
diff --git a/src/utilities/common/__tests__/stripTypename.ts b/src/utilities/common/__tests__/stripTypename.ts
--- a/src/utilities/common/__tests__/stripTypename.ts
+++ b/src/utilities/common/__tests__/stripTypename.ts
@@ -1,3 +1,4 @@
+import equal from '@wry/equality';
 import { stripTypename } from '../stripTypename';
 
 test('omits __typename from a shallow object', () => {
@@ -58,3 +59,53 @@ test('returns primitives unchanged', () => {
   expect(stripTypename(null)).toBe(null);
   expect(stripTypename(undefined)).toBe(undefined);
 });
+
+test('keeps __typename for paths allowed by the `keep` option', () => {
+  const variables = {
+    __typename: 'Foo',
+    bar: {
+      __typename: 'Bar',
+      aa: true,
+    },
+    baz: {
+      __typename: 'Baz',
+      bb: true,
+    },
+    deeply: {
+      __typename: 'Deeply',
+      nested: {
+        __typename: 'Nested',
+        value: 'value',
+      },
+    },
+  };
+
+  const result = stripTypename(variables, {
+    keep: (path) => {
+      if (equal(path, ['bar', '__typename'])) {
+        return true;
+      }
+
+      if (equal(path, ['deeply'])) {
+        return stripTypename.BREAK;
+      }
+    },
+  });
+
+  expect(result).toStrictEqual({
+    bar: {
+      __typename: 'Bar',
+      aa: true,
+    },
+    baz: {
+      bb: true,
+    },
+    deeply: {
+      __typename: 'Deeply',
+      nested: {
+        __typename: 'Nested',
+        value: 'value',
+      },
+    },
+  });
+});