Repository: graphql/graphql-js
Pull request #3513 (2022-03-18 14:19:31)

Implement OneOf Input Objects via `@oneOf` directive
This PR implements graphql/graphql-spec#825 to add a `@oneOf` directive that indicates ~~an Object is a OneOf Object or~~ an Input Object is a OneOf Input Object. We then validate ~~OneOf Objects and~~ OneOf Input Objects conform to the spec during the validation and execution phases.

Closes graphql/graphql-wg#648

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/12261526/38307428/3513/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li>:x: login: erikkessler1 / name: Erik Kessler  (99189d9a6baf811ca9e4e4ce691918703e3f308f, 534b243baa5a3b0b101c7dba7741583a447d661a, 25928e3174fc342d02b58ef270b6cc3474fa2b2e, 24f0d9528a0c7d6eccff9dd1a02ed8140ab1e402, 15e39f5b10e450a3913af0a4777b21afa77fd30f, 50bb96844f527498d57f640e433863fcb768872a). This user is authorized, but they must confirm their affiliation with their company. Start the authorization process <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/12261526/38307428/3513/#/?version=2' target='_blank'> by clicking here</a>, click "Corporate",select the appropriate company from the list, then confirm your affiliation on the page that appears. For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul>
netlify[bot]: 👷 Deploy Preview for *compassionate-pike-271cb3* processing.


🔨 Explore the source changes: 50bb96844f527498d57f640e433863fcb768872a

🔍 Inspect the deploy log: [https://app.netlify.com/sites/compassionate-pike-271cb3/deploys/623494f6367d230009b931a7](https://app.netlify.com/sites/compassionate-pike-271cb3/deploys/623494f6367d230009b931a7)

erikkessler1: > As for oneOf input objects, this is all the spec PR addresses currently; would it make sense to cut down this PR to only deal with oneOf input objects to get this closer to review?

Thank you for taking a look! I agree that focusing on oneOf input objects makes sense, so I've stripped out everything around oneOf objects.
github-actions[bot]: > > As for oneOf input objects, this is all the spec PR addresses currently; would it make sense to cut down this PR to only deal with oneOf input objects to get this closer to review?
> 
> Thank you for taking a look! I agree that focusing on oneOf input objects makes sense, so I've stripped out everything around oneOf objects.

@erikkessler1 Unknown command, please check help message at the top of PR.
benjie: Odd, all my comments seem to have been duplicated; I've deleted the duplicates.
github-actions[bot]: > Odd, all my comments seem to have been duplicated; I've deleted the duplicates.

@benjie Unknown command, please check help message at the top of PR.
erikkessler1: I've added some test coverage for those cases including the `variables: { a: "abc", b: undefined }` case.
benjie: Pushed a commit that hopefully deals with the code coverage issue :+1: 
benjie: Merged main; CI passing now :raised_hands: 
benjie: Great feedback, thanks @IvanGoncharov!
benjie: @graphql/graphql-js-reviewers I believe this is ready for review and possibly merge into v17 👍
seamusml: Hi 👋, is there any outlook on when this might get merged and published? :)


benjie: @graphql/graphql-js-reviewers I've merged in the latest `main`, so it should be good for review 👍 

@IvanGoncharov Can you dismiss your stale review, please?
saltman424: @IvanGoncharov any chance you can dismiss the review @benjie mentioned above? I know this has been a long awaited feature for many
IvanGoncharov: Sorry missed that spec proposal in stage 2, so this PR can actually be merged.
Also missed all the comments (my inbox is in bad shape atm).
@benjie Thank you for the ping 👍 

@erikkessler1 If you don't mind, I will address stylistic changes by just committing on top of your PR.
I think it would be faster that way, it takes a lot of time to comment on a lot of tiny changes.

erikkessler1: > If you don't mind, I will address stylistic changes by just committing on top of your PR.
I think it would be faster that way, it takes a lot of time to comment on a lot of tiny changes.

@IvanGoncharov that sounds good to me!

Patch:
diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts
new file mode 100644
index 00000000..f48e62e6
--- /dev/null
+++ b/src/execution/IncrementalPublisher.ts
@@ -0,0 +1,491 @@
+import type { ObjMap } from '../jsutils/ObjMap.js';
+import type { Path } from '../jsutils/Path.js';
+import { pathToArray } from '../jsutils/Path.js';
+import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
+
+import type {
+  GraphQLError,
+  GraphQLFormattedError,
+} from '../error/GraphQLError.js';
+
+export interface SubsequentIncrementalExecutionResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  hasNext: boolean;
+  incremental?: ReadonlyArray<IncrementalResult<TData, TExtensions>>;
+  extensions?: TExtensions;
+}
+
+export interface FormattedSubsequentIncrementalExecutionResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  hasNext: boolean;
+  incremental?: ReadonlyArray<FormattedIncrementalResult<TData, TExtensions>>;
+  extensions?: TExtensions;
+}
+
+export interface IncrementalDeferResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  errors?: ReadonlyArray<GraphQLError>;
+  data?: TData | null;
+  path?: ReadonlyArray<string | number>;
+  label?: string;
+  extensions?: TExtensions;
+}
+
+export interface FormattedIncrementalDeferResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  errors?: ReadonlyArray<GraphQLFormattedError>;
+  data?: TData | null;
+  path?: ReadonlyArray<string | number>;
+  label?: string;
+  extensions?: TExtensions;
+}
+
+export interface IncrementalStreamResult<
+  TData = Array<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  errors?: ReadonlyArray<GraphQLError>;
+  items?: TData | null;
+  path?: ReadonlyArray<string | number>;
+  label?: string;
+  extensions?: TExtensions;
+}
+
+export interface FormattedIncrementalStreamResult<
+  TData = Array<unknown>,
+  TExtensions = ObjMap<unknown>,
+> {
+  errors?: ReadonlyArray<GraphQLFormattedError>;
+  items?: TData | null;
+  path?: ReadonlyArray<string | number>;
+  label?: string;
+  extensions?: TExtensions;
+}
+
+export type IncrementalResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> =
+  | IncrementalDeferResult<TData, TExtensions>
+  | IncrementalStreamResult<TData, TExtensions>;
+
+export type FormattedIncrementalResult<
+  TData = ObjMap<unknown>,
+  TExtensions = ObjMap<unknown>,
+> =
+  | FormattedIncrementalDeferResult<TData, TExtensions>
+  | FormattedIncrementalStreamResult<TData, TExtensions>;
+
+/**
+ * This class is used to publish incremental results to the client, enabling semi-concurrent
+ * execution while preserving result order.
+ *
+ * The internal publishing state is managed as follows:
+ *
+ * '_released': the set of Incremental Data records that are ready to be sent to the client,
+ * i.e. their parents have completed and they have also completed.
+ *
+ * `_pending`: the set of Incremental Data records that are definitely pending, i.e. their
+ * parents have completed so that they can no longer be filtered. This includes all Incremental
+ * Data records in `released`, as well as Incremental Data records that have not yet completed.
+ *
+ * `_initialResult`: a record containing the state of the initial result, as follows:
+ * `isCompleted`: indicates whether the initial result has completed.
+ * `children`: the set of Incremental Data records that can be be published when the initial
+ * result is completed.
+ *
+ * Each Incremental Data record also contains similar metadata, i.e. these records also contain
+ * similar `isCompleted` and `children` properties.
+ *
+ * @internal
+ */
+export class IncrementalPublisher {
+  private _initialResult: {
+    children: Set<IncrementalDataRecord>;
+    isCompleted: boolean;
+  };
+
+  private _released: Set<IncrementalDataRecord>;
+  private _pending: Set<IncrementalDataRecord>;
+
+  // these are assigned within the Promise executor called synchronously within the constructor
+  private _signalled!: Promise<unknown>;
+  private _resolve!: () => void;
+
+  constructor() {
+    this._initialResult = {
+      children: new Set(),
+      isCompleted: false,
+    };
+    this._released = new Set();
+    this._pending = new Set();
+    this._reset();
+  }
+
+  hasNext(): boolean {
+    return this._pending.size > 0;
+  }
+
+  subscribe(): AsyncGenerator<
+    SubsequentIncrementalExecutionResult,
+    void,
+    void
+  > {
+    let isDone = false;
+
+    const _next = async (): Promise<
+      IteratorResult<SubsequentIncrementalExecutionResult, void>
+    > => {
+      // eslint-disable-next-line no-constant-condition
+      while (true) {
+        if (isDone) {
+          return { value: undefined, done: true };
+        }
+
+        for (const item of this._released) {
+          this._pending.delete(item);
+        }
+        const released = this._released;
+        this._released = new Set();
+
+        const result = this._getIncrementalResult(released);
+
+        if (!this.hasNext()) {
+          isDone = true;
+        }
+
+        if (result !== undefined) {
+          return { value: result, done: false };
+        }
+
+        // eslint-disable-next-line no-await-in-loop
+        await this._signalled;
+      }
+    };
+
+    const returnStreamIterators = async (): Promise<void> => {
+      const promises: Array<Promise<IteratorResult<unknown>>> = [];
+      this._pending.forEach((incrementalDataRecord) => {
+        if (
+          isStreamItemsRecord(incrementalDataRecord) &&
+          incrementalDataRecord.asyncIterator?.return
+        ) {
+          promises.push(incrementalDataRecord.asyncIterator.return());
+        }
+      });
+      await Promise.all(promises);
+    };
+
+    const _return = async (): Promise<
+      IteratorResult<SubsequentIncrementalExecutionResult, void>
+    > => {
+      isDone = true;
+      await returnStreamIterators();
+      return { value: undefined, done: true };
+    };
+
+    const _throw = async (
+      error?: unknown,
+    ): Promise<IteratorResult<SubsequentIncrementalExecutionResult, void>> => {
+      isDone = true;
+      await returnStreamIterators();
+      return Promise.reject(error);
+    };
+
+    return {
+      [Symbol.asyncIterator]() {
+        return this;
+      },
+      next: _next,
+      return: _return,
+      throw: _throw,
+    };
+  }
+
+  prepareNewDeferredFragmentRecord(opts: {
+    label: string | undefined;
+    path: Path | undefined;
+    parentContext: IncrementalDataRecord | undefined;
+  }): DeferredFragmentRecord {
+    const deferredFragmentRecord = new DeferredFragmentRecord(opts);
+
+    const parentContext = opts.parentContext;
+    if (parentContext) {
+      parentContext.children.add(deferredFragmentRecord);
+    } else {
+      this._initialResult.children.add(deferredFragmentRecord);
+    }
+
+    return deferredFragmentRecord;
+  }
+
+  prepareNewStreamItemsRecord(opts: {
+    label: string | undefined;
+    path: Path | undefined;
+    asyncIterator?: AsyncIterator<unknown>;
+    parentContext: IncrementalDataRecord | undefined;
+  }): StreamItemsRecord {
+    const streamItemsRecord = new StreamItemsRecord(opts);
+
+    const parentContext = opts.parentContext;
+    if (parentContext) {
+      parentContext.children.add(streamItemsRecord);
+    } else {
+      this._initialResult.children.add(streamItemsRecord);
+    }
+
+    return streamItemsRecord;
+  }
+
+  completeDeferredFragmentRecord(
+    deferredFragmentRecord: DeferredFragmentRecord,
+    data: ObjMap<unknown> | null,
+  ): void {
+    deferredFragmentRecord.data = data;
+    deferredFragmentRecord.isCompleted = true;
+    this._release(deferredFragmentRecord);
+  }
+
+  completeStreamItemsRecord(
+    streamItemsRecord: StreamItemsRecord,
+    items: Array<unknown> | null,
+  ) {
+    streamItemsRecord.items = items;
+    streamItemsRecord.isCompleted = true;
+    this._release(streamItemsRecord);
+  }
+
+  setIsCompletedAsyncIterator(streamItemsRecord: StreamItemsRecord) {
+    streamItemsRecord.isCompletedAsyncIterator = true;
+  }
+
+  addFieldError(
+    incrementalDataRecord: IncrementalDataRecord,
+    error: GraphQLError,
+  ) {
+    incrementalDataRecord.errors.push(error);
+  }
+
+  publishInitial() {
+    for (const child of this._initialResult.children) {
+      this._publish(child);
+    }
+  }
+
+  filter(
+    nullPath: Path,
+    erroringIncrementalDataRecord: IncrementalDataRecord | undefined,
+  ) {
+    const nullPathArray = pathToArray(nullPath);
+
+    const asyncIterators = new Set<AsyncIterator<unknown>>();
+
+    const children =
+      erroringIncrementalDataRecord === undefined
+        ? this._initialResult.children
+        : erroringIncrementalDataRecord.children;
+
+    for (const child of this._getDescendants(children)) {
+      if (!this._matchesPath(child.path, nullPathArray)) {
+        continue;
+      }
+
+      this._delete(child);
+      const parent =
+        child.parentContext === undefined
+          ? this._initialResult
+          : child.parentContext;
+      parent.children.delete(child);
+
+      if (isStreamItemsRecord(child)) {
+        if (child.asyncIterator !== undefined) {
+          asyncIterators.add(child.asyncIterator);
+        }
+      }
+    }
+
+    asyncIterators.forEach((asyncIterator) => {
+      asyncIterator.return?.().catch(() => {
+        // ignore error
+      });
+    });
+  }
+
+  private _trigger() {
+    this._resolve();
+    this._reset();
+  }
+
+  private _reset() {
+    // promiseWithResolvers uses void only as a generic type parameter
+    // see: https://typescript-eslint.io/rules/no-invalid-void-type/
+    // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+    const { promise: signalled, resolve } = promiseWithResolvers<void>();
+    this._resolve = resolve;
+    this._signalled = signalled;
+  }
+
+  private _introduce(item: IncrementalDataRecord) {
+    this._pending.add(item);
+  }
+
+  private _release(item: IncrementalDataRecord): void {
+    if (this._pending.has(item)) {
+      this._released.add(item);
+      this._trigger();
+    }
+  }
+
+  private _push(item: IncrementalDataRecord): void {
+    this._released.add(item);
+    this._pending.add(item);
+    this._trigger();
+  }
+
+  private _delete(item: IncrementalDataRecord) {
+    this._released.delete(item);
+    this._pending.delete(item);
+    this._trigger();
+  }
+
+  private _getIncrementalResult(
+    completedRecords: ReadonlySet<IncrementalDataRecord>,
+  ): SubsequentIncrementalExecutionResult | undefined {
+    const incrementalResults: Array<IncrementalResult> = [];
+    let encounteredCompletedAsyncIterator = false;
+    for (const incrementalDataRecord of completedRecords) {
+      const incrementalResult: IncrementalResult = {};
+      for (const child of incrementalDataRecord.children) {
+        this._publish(child);
+      }
+      if (isStreamItemsRecord(incrementalDataRecord)) {
+        const items = incrementalDataRecord.items;
+        if (incrementalDataRecord.isCompletedAsyncIterator) {
+          // async iterable resolver just finished but there may be pending payloads
+          encounteredCompletedAsyncIterator = true;
+          continue;
+        }
+        (incrementalResult as IncrementalStreamResult).items = items;
+      } else {
+        const data = incrementalDataRecord.data;
+        (incrementalResult as IncrementalDeferResult).data = data ?? null;
+      }
+
+      incrementalResult.path = incrementalDataRecord.path;
+      if (incrementalDataRecord.label != null) {
+        incrementalResult.label = incrementalDataRecord.label;
+      }
+      if (incrementalDataRecord.errors.length > 0) {
+        incrementalResult.errors = incrementalDataRecord.errors;
+      }
+      incrementalResults.push(incrementalResult);
+    }
+
+    return incrementalResults.length
+      ? { incremental: incrementalResults, hasNext: this.hasNext() }
+      : encounteredCompletedAsyncIterator && !this.hasNext()
+      ? { hasNext: false }
+      : undefined;
+  }
+
+  private _publish(incrementalDataRecord: IncrementalDataRecord) {
+    if (incrementalDataRecord.isCompleted) {
+      this._push(incrementalDataRecord);
+    } else {
+      this._introduce(incrementalDataRecord);
+    }
+  }
+
+  private _getDescendants(
+    children: ReadonlySet<IncrementalDataRecord>,
+    descendants = new Set<IncrementalDataRecord>(),
+  ): ReadonlySet<IncrementalDataRecord> {
+    for (const child of children) {
+      descendants.add(child);
+      this._getDescendants(child.children, descendants);
+    }
+    return descendants;
+  }
+
+  private _matchesPath(
+    testPath: Array<string | number>,
+    basePath: Array<string | number>,
+  ): boolean {
+    for (let i = 0; i < basePath.length; i++) {
+      if (basePath[i] !== testPath[i]) {
+        // testPath points to a path unaffected at basePath
+        return false;
+      }
+    }
+    return true;
+  }
+}
+
+/** @internal */
+export class DeferredFragmentRecord {
+  errors: Array<GraphQLError>;
+  label: string | undefined;
+  path: Array<string | number>;
+  data: ObjMap<unknown> | null;
+  parentContext: IncrementalDataRecord | undefined;
+  children: Set<IncrementalDataRecord>;
+  isCompleted: boolean;
+  constructor(opts: {
+    label: string | undefined;
+    path: Path | undefined;
+    parentContext: IncrementalDataRecord | undefined;
+  }) {
+    this.label = opts.label;
+    this.path = pathToArray(opts.path);
+    this.parentContext = opts.parentContext;
+    this.errors = [];
+    this.children = new Set();
+    this.isCompleted = false;
+    this.data = null;
+  }
+}
+
+/** @internal */
+export class StreamItemsRecord {
+  errors: Array<GraphQLError>;
+  label: string | undefined;
+  path: Array<string | number>;
+  items: Array<unknown> | null;
+  parentContext: IncrementalDataRecord | undefined;
+  children: Set<IncrementalDataRecord>;
+  asyncIterator: AsyncIterator<unknown> | undefined;
+  isCompletedAsyncIterator?: boolean;
+  isCompleted: boolean;
+  constructor(opts: {
+    label: string | undefined;
+    path: Path | undefined;
+    asyncIterator?: AsyncIterator<unknown>;
+    parentContext: IncrementalDataRecord | undefined;
+  }) {
+    this.items = null;
+    this.label = opts.label;
+    this.path = pathToArray(opts.path);
+    this.parentContext = opts.parentContext;
+    this.asyncIterator = opts.asyncIterator;
+    this.errors = [];
+    this.children = new Set();
+    this.isCompleted = false;
+    this.items = null;
+  }
+}
+
+export type IncrementalDataRecord = DeferredFragmentRecord | StreamItemsRecord;
+
+function isStreamItemsRecord(
+  incrementalDataRecord: IncrementalDataRecord,
+): incrementalDataRecord is StreamItemsRecord {
+  return incrementalDataRecord instanceof StreamItemsRecord;
+}
diff --git a/src/execution/execute.ts b/src/execution/execute.ts
index 0c68760d..1ec11f72 100644
--- a/src/execution/execute.ts
+++ b/src/execution/execute.ts
@@ -52,6 +52,14 @@ import {
   collectFields,
   collectSubfields as _collectSubfields,
 } from './collectFields.js';
+import type {
+  FormattedIncrementalResult,
+  IncrementalDataRecord,
+  IncrementalResult,
+  StreamItemsRecord,
+  SubsequentIncrementalExecutionResult,
+} from './IncrementalPublisher.js';
+import { IncrementalPublisher } from './IncrementalPublisher.js';
 import { mapAsyncIterable } from './mapAsyncIterable.js';
 import {
   getArgumentValues,
@@ -121,7 +129,7 @@ export interface ExecutionContext {
   typeResolver: GraphQLTypeResolver<any, any>;
   subscribeFieldResolver: GraphQLFieldResolver<any, any>;
   errors: Array<GraphQLError>;
-  subsequentPayloads: Set<IncrementalDataRecord>;
+  incrementalPublisher: IncrementalPublisher;
 }
 
 /**
@@ -181,76 +189,6 @@ export interface FormattedInitialIncrementalExecutionResult<
   extensions?: TExtensions;
 }
 
-export interface SubsequentIncrementalExecutionResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> {
-  hasNext: boolean;
-  incremental?: ReadonlyArray<IncrementalResult<TData, TExtensions>>;
-  extensions?: TExtensions;
-}
-
-export interface FormattedSubsequentIncrementalExecutionResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> {
-  hasNext: boolean;
-  incremental?: ReadonlyArray<FormattedIncrementalResult<TData, TExtensions>>;
-  extensions?: TExtensions;
-}
-
-export interface IncrementalDeferResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> extends ExecutionResult<TData, TExtensions> {
-  path?: ReadonlyArray<string | number>;
-  label?: string;
-}
-
-export interface FormattedIncrementalDeferResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> extends FormattedExecutionResult<TData, TExtensions> {
-  path?: ReadonlyArray<string | number>;
-  label?: string;
-}
-
-export interface IncrementalStreamResult<
-  TData = Array<unknown>,
-  TExtensions = ObjMap<unknown>,
-> {
-  errors?: ReadonlyArray<GraphQLError>;
-  items?: TData | null;
-  path?: ReadonlyArray<string | number>;
-  label?: string;
-  extensions?: TExtensions;
-}
-
-export interface FormattedIncrementalStreamResult<
-  TData = Array<unknown>,
-  TExtensions = ObjMap<unknown>,
-> {
-  errors?: ReadonlyArray<GraphQLFormattedError>;
-  items?: TData | null;
-  path?: ReadonlyArray<string | number>;
-  label?: string;
-  extensions?: TExtensions;
-}
-
-export type IncrementalResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> =
-  | IncrementalDeferResult<TData, TExtensions>
-  | IncrementalStreamResult<TData, TExtensions>;
-
-export type FormattedIncrementalResult<
-  TData = ObjMap<unknown>,
-  TExtensions = ObjMap<unknown>,
-> =
-  | FormattedIncrementalDeferResult<TData, TExtensions>
-  | FormattedIncrementalStreamResult<TData, TExtensions>;
-
 export interface ExecutionArgs {
   schema: GraphQLSchema;
   document: DocumentNode;
@@ -351,43 +289,46 @@ function executeImpl(
   // Errors from sub-fields of a NonNull type may propagate to the top level,
   // at which point we still log the error and null the parent field, which
   // in this case is the entire response.
+  const { incrementalPublisher, errors } = exeContext;
   try {
     const result = executeOperation(exeContext);
     if (isPromise(result)) {
       return result.then(
         (data) => {
-          const initialResult = buildResponse(data, exeContext.errors);
-          if (exeContext.subsequentPayloads.size > 0) {
+          const initialResult = buildResponse(data, errors);
+          incrementalPublisher.publishInitial();
+          if (incrementalPublisher.hasNext()) {
             return {
               initialResult: {
                 ...initialResult,
                 hasNext: true,
               },
-              subsequentResults: yieldSubsequentPayloads(exeContext),
+              subsequentResults: incrementalPublisher.subscribe(),
             };
           }
           return initialResult;
         },
         (error) => {
-          exeContext.errors.push(error);
-          return buildResponse(null, exeContext.errors);
+          errors.push(error);
+          return buildResponse(null, errors);
         },
       );
     }
-    const initialResult = buildResponse(result, exeContext.errors);
-    if (exeContext.subsequentPayloads.size > 0) {
+    const initialResult = buildResponse(result, errors);
+    incrementalPublisher.publishInitial();
+    if (incrementalPublisher.hasNext()) {
       return {
         initialResult: {
           ...initialResult,
           hasNext: true,
         },
-        subsequentResults: yieldSubsequentPayloads(exeContext),
+        subsequentResults: incrementalPublisher.subscribe(),
       };
     }
     return initialResult;
   } catch (error) {
-    exeContext.errors.push(error);
-    return buildResponse(null, exeContext.errors);
+    errors.push(error);
+    return buildResponse(null, errors);
   }
 }
 
@@ -503,7 +444,7 @@ export function buildExecutionContext(
     fieldResolver: fieldResolver ?? defaultFieldResolver,
     typeResolver: typeResolver ?? defaultTypeResolver,
     subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
-    subsequentPayloads: new Set(),
+    incrementalPublisher: new IncrementalPublisher(),
     errors: [],
   };
 }
@@ -515,7 +456,7 @@ function buildPerEventExecutionContext(
   return {
     ...exeContext,
     rootValue: payload,
-    subsequentPayloads: new Set(),
+    // no need to update incrementalPublisher, incremental delivery is not supported for subscriptions
     errors: [],
   };
 }
@@ -768,7 +709,7 @@ function executeField(
           path,
           incrementalDataRecord,
         );
-        filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
+        exeContext.incrementalPublisher.filter(path, incrementalDataRecord);
         return null;
       });
     }
@@ -782,7 +723,7 @@ function executeField(
       path,
       incrementalDataRecord,
     );
-    filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
+    exeContext.incrementalPublisher.filter(path, incrementalDataRecord);
     return null;
   }
 }
@@ -983,7 +924,7 @@ async function completePromisedValue(
       path,
       incrementalDataRecord,
     );
-    filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
+    exeContext.incrementalPublisher.filter(path, incrementalDataRecord);
     return null;
   }
 }
@@ -1093,16 +1034,7 @@ async function completeAsyncIteratorValue(
         break;
       }
     } catch (rawError) {
-      handleFieldError(
-        rawError,
-        exeContext,
-        itemType,
-        fieldGroup,
-        itemPath,
-        incrementalDataRecord,
-      );
-      completedResults.push(null);
-      break;
+      throw locatedError(rawError, fieldGroup, pathToArray(path));
     }
 
     if (
@@ -1268,7 +1200,10 @@ function completeListItemValue(
             itemPath,
             incrementalDataRecord,
           );
-          filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+          exeContext.incrementalPublisher.filter(
+            itemPath,
+            incrementalDataRecord,
+          );
           return null;
         }),
       );
@@ -1286,7 +1221,7 @@ function completeListItemValue(
       itemPath,
       incrementalDataRecord,
     );
-    filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+    exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord);
     completedResults.push(null);
   }
 
@@ -1816,12 +1751,14 @@ function executeDeferredFragment(
   path?: Path,
   parentContext?: IncrementalDataRecord,
 ): void {
-  const incrementalDataRecord = new DeferredFragmentRecord({
-    label,
-    path,
-    parentContext,
-    exeContext,
-  });
+  const incrementalPublisher = exeContext.incrementalPublisher;
+  const incrementalDataRecord =
+    incrementalPublisher.prepareNewDeferredFragmentRecord({
+      label,
+      path,
+      parentContext,
+    });
+
   let promiseOrData;
   try {
     promiseOrData = executeFields(
@@ -1834,16 +1771,33 @@ function executeDeferredFragment(
     );
 
     if (isPromise(promiseOrData)) {
-      promiseOrData = promiseOrData.then(null, (e) => {
-        incrementalDataRecord.errors.push(e);
-        return null;
-      });
+      promiseOrData = promiseOrData.then(
+        (resolved) =>
+          incrementalPublisher.completeDeferredFragmentRecord(
+            incrementalDataRecord,
+            resolved,
+          ),
+        (e) => {
+          incrementalPublisher.addFieldError(incrementalDataRecord, e);
+          incrementalPublisher.completeDeferredFragmentRecord(
+            incrementalDataRecord,
+            null,
+          );
+        },
+      );
+    } else {
+      incrementalPublisher.completeDeferredFragmentRecord(
+        incrementalDataRecord,
+        promiseOrData,
+      );
     }
   } catch (e) {
-    incrementalDataRecord.errors.push(e);
-    promiseOrData = null;
+    incrementalPublisher.addFieldError(incrementalDataRecord, e);
+    incrementalPublisher.completeDeferredFragmentRecord(
+      incrementalDataRecord,
+      null,
+    );
   }
-  incrementalDataRecord.addData(promiseOrData);
 }
 
 function executeStreamField(
@@ -1857,14 +1811,16 @@ function executeStreamField(
   label?: string,
   parentContext?: IncrementalDataRecord,
 ): IncrementalDataRecord {
-  const incrementalDataRecord = new StreamItemsRecord({
-    label,
-    path: itemPath,
-    parentContext,
-    exeContext,
-  });
+  const incrementalPublisher = exeContext.incrementalPublisher;
+  const incrementalDataRecord =
+    incrementalPublisher.prepareNewStreamItemsRecord({
+      label,
+      path: itemPath,
+      parentContext,
+    });
+
   if (isPromise(item)) {
-    const completedItems = completePromisedValue(
+    completePromisedValue(
       exeContext,
       itemType,
       fieldGroup,
@@ -1873,15 +1829,21 @@ function executeStreamField(
       item,
       incrementalDataRecord,
     ).then(
-      (value) => [value],
+      (value) =>
+        incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [
+          value,
+        ]),
       (error) => {
-        incrementalDataRecord.errors.push(error);
-        filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
+        incrementalPublisher.addFieldError(incrementalDataRecord, error);
+        incrementalPublisher.filter(path, incrementalDataRecord);
+        incrementalPublisher.completeStreamItemsRecord(
+          incrementalDataRecord,
+          null,
+        );
         return null;
       },
     );
 
-    incrementalDataRecord.addItems(completedItems);
     return incrementalDataRecord;
   }
 
@@ -1907,17 +1869,17 @@ function executeStreamField(
         incrementalDataRecord,
       );
       completedItem = null;
-      filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+      exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord);
     }
   } catch (error) {
-    incrementalDataRecord.errors.push(error);
-    filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
-    incrementalDataRecord.addItems(null);
+    incrementalPublisher.addFieldError(incrementalDataRecord, error);
+    incrementalPublisher.filter(path, incrementalDataRecord);
+    incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, null);
     return incrementalDataRecord;
   }
 
   if (isPromise(completedItem)) {
-    const completedItems = completedItem
+    completedItem
       .then(undefined, (rawError) => {
         handleFieldError(
           rawError,
@@ -1927,23 +1889,31 @@ function executeStreamField(
           itemPath,
           incrementalDataRecord,
         );
-        filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+        exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord);
         return null;
       })
       .then(
-        (value) => [value],
+        (value) =>
+          incrementalPublisher.completeStreamItemsRecord(
+            incrementalDataRecord,
+            [value],
+          ),
         (error) => {
-          incrementalDataRecord.errors.push(error);
-          filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
-          return null;
+          incrementalPublisher.addFieldError(incrementalDataRecord, error);
+          incrementalPublisher.filter(path, incrementalDataRecord);
+          incrementalPublisher.completeStreamItemsRecord(
+            incrementalDataRecord,
+            null,
+          );
         },
       );
 
-    incrementalDataRecord.addItems(completedItems);
     return incrementalDataRecord;
   }
 
-  incrementalDataRecord.addItems([completedItem]);
+  incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [
+    completedItem,
+  ]);
   return incrementalDataRecord;
 }
 
@@ -1954,27 +1924,22 @@ async function executeStreamAsyncIteratorItem(
   info: GraphQLResolveInfo,
   itemType: GraphQLOutputType,
   incrementalDataRecord: StreamItemsRecord,
+  path: Path,
   itemPath: Path,
 ): Promise<IteratorResult<unknown>> {
   let item;
   try {
     const { value, done } = await asyncIterator.next();
+
     if (done) {
-      incrementalDataRecord.setIsCompletedAsyncIterator();
-      return { done, value: undefined };
+      exeContext.incrementalPublisher.setIsCompletedAsyncIterator(
+        incrementalDataRecord,
+      );
+      return { done: true, value: undefined };
     }
     item = value;
   } catch (rawError) {
-    handleFieldError(
-      rawError,
-      exeContext,
-      itemType,
-      fieldGroup,
-      itemPath,
-      incrementalDataRecord,
-    );
-    // don't continue if async iterator throws
-    return { done: true, value: null };
+    throw locatedError(rawError, fieldGroup, pathToArray(path));
   }
   let completedItem;
   try {
@@ -1998,7 +1963,7 @@ async function executeStreamAsyncIteratorItem(
           itemPath,
           incrementalDataRecord,
         );
-        filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+        exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord);
         return null;
       });
     }
@@ -2012,7 +1977,7 @@ async function executeStreamAsyncIteratorItem(
       itemPath,
       incrementalDataRecord,
     );
-    filterSubsequentPayloads(exeContext, itemPath, incrementalDataRecord);
+    exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord);
     return { done: false, value: null };
   }
 }
@@ -2028,18 +1993,19 @@ async function executeStreamAsyncIterator(
   label?: string,
   parentContext?: IncrementalDataRecord,
 ): Promise<void> {
+  const incrementalPublisher = exeContext.incrementalPublisher;
   let index = initialIndex;
   let previousIncrementalDataRecord = parentContext ?? undefined;
   // eslint-disable-next-line no-constant-condition
   while (true) {
     const itemPath = addPath(path, index, undefined);
-    const incrementalDataRecord = new StreamItemsRecord({
-      label,
-      path: itemPath,
-      parentContext: previousIncrementalDataRecord,
-      asyncIterator,
-      exeContext,
-    });
+    const incrementalDataRecord =
+      incrementalPublisher.prepareNewStreamItemsRecord({
+        label,
+        path: itemPath,
+        parentContext: previousIncrementalDataRecord,
+        asyncIterator,
+      });
 
     let iteration;
     try {
@@ -2051,12 +2017,16 @@ async function executeStreamAsyncIterator(
         info,
         itemType,
         incrementalDataRecord,
+        path,
         itemPath,
       );
     } catch (error) {
-      incrementalDataRecord.errors.push(error);
-      filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
-      incrementalDataRecord.addItems(null);
+      incrementalPublisher.addFieldError(incrementalDataRecord, error);
+      incrementalPublisher.filter(path, incrementalDataRecord);
+      incrementalPublisher.completeStreamItemsRecord(
+        incrementalDataRecord,
+        null,
+      );
       // entire stream has errored and bubbled upwards
       if (asyncIterator?.return) {
         asyncIterator.return().catch(() => {
@@ -2068,22 +2038,28 @@ async function executeStreamAsyncIterator(
 
     const { done, value: completedItem } = iteration;
 
-    let completedItems: PromiseOrValue<Array<unknown> | null>;
     if (isPromise(completedItem)) {
-      completedItems = completedItem.then(
-        (value) => [value],
+      completedItem.then(
+        (value) =>
+          incrementalPublisher.completeStreamItemsRecord(
+            incrementalDataRecord,
+            [value],
+          ),
         (error) => {
-          incrementalDataRecord.errors.push(error);
-          filterSubsequentPayloads(exeContext, path, incrementalDataRecord);
-          return null;
+          incrementalPublisher.addFieldError(incrementalDataRecord, error);
+          incrementalPublisher.filter(path, incrementalDataRecord);
+          incrementalPublisher.completeStreamItemsRecord(
+            incrementalDataRecord,
+            null,
+          );
         },
       );
     } else {
-      completedItems = [completedItem];
+      incrementalPublisher.completeStreamItemsRecord(incrementalDataRecord, [
+        completedItem,
+      ]);
     }
 
-    incrementalDataRecord.addItems(completedItems);
-
     if (done) {
       break;
     }
@@ -2091,249 +2067,3 @@ async function executeStreamAsyncIterator(
     index++;
   }
 }
-
-function filterSubsequentPayloads(
-  exeContext: ExecutionContext,
-  nullPath: Path,
-  currentIncrementalDataRecord: IncrementalDataRecord | undefined,
-): void {
-  const nullPathArray = pathToArray(nullPath);
-  exeContext.subsequentPayloads.forEach((incrementalDataRecord) => {
-    if (incrementalDataRecord === currentIncrementalDataRecord) {
-      // don't remove payload from where error originates
-      return;
-    }
-    for (let i = 0; i < nullPathArray.length; i++) {
-      if (incrementalDataRecord.path[i] !== nullPathArray[i]) {
-        // incrementalDataRecord points to a path unaffected by this payload
-        return;
-      }
-    }
-    // incrementalDataRecord path points to nulled error field
-    if (
-      isStreamItemsRecord(incrementalDataRecord) &&
-      incrementalDataRecord.asyncIterator?.return
-    ) {
-      incrementalDataRecord.asyncIterator.return().catch(() => {
-        // ignore error
-      });
-    }
-    exeContext.subsequentPayloads.delete(incrementalDataRecord);
-  });
-}
-
-function getCompletedIncrementalResults(
-  exeContext: ExecutionContext,
-): Array<IncrementalResult> {
-  const incrementalResults: Array<IncrementalResult> = [];
-  for (const incrementalDataRecord of exeContext.subsequentPayloads) {
-    const incrementalResult: IncrementalResult = {};
-    if (!incrementalDataRecord.isCompleted) {
-      continue;
-    }
-    exeContext.subsequentPayloads.delete(incrementalDataRecord);
-    if (isStreamItemsRecord(incrementalDataRecord)) {
-      const items = incrementalDataRecord.items;
-      if (incrementalDataRecord.isCompletedAsyncIterator) {
-        // async iterable resolver just finished but there may be pending payloads
-        continue;
-      }
-      (incrementalResult as IncrementalStreamResult).items = items;
-    } else {
-      const data = incrementalDataRecord.data;
-      (incrementalResult as IncrementalDeferResult).data = data ?? null;
-    }
-
-    incrementalResult.path = incrementalDataRecord.path;
-    if (incrementalDataRecord.label != null) {
-      incrementalResult.label = incrementalDataRecord.label;
-    }
-    if (incrementalDataRecord.errors.length > 0) {
-      incrementalResult.errors = incrementalDataRecord.errors;
-    }
-    incrementalResults.push(incrementalResult);
-  }
-  return incrementalResults;
-}
-
-function yieldSubsequentPayloads(
-  exeContext: ExecutionContext,
-): AsyncGenerator<SubsequentIncrementalExecutionResult, void, void> {
-  let isDone = false;
-
-  async function next(): Promise<
-    IteratorResult<SubsequentIncrementalExecutionResult, void>
-  > {
-    if (isDone) {
-      return { value: undefined, done: true };
-    }
-
-    await Promise.race(
-      Array.from(exeContext.subsequentPayloads).map((p) => p.promise),
-    );
-
-    if (isDone) {
-      // a different call to next has exhausted all payloads
-      return { value: undefined, done: true };
-    }
-
-    const incremental = getCompletedIncrementalResults(exeContext);
-    const hasNext = exeContext.subsequentPayloads.size > 0;
-
-    if (!incremental.length && hasNext) {
-      return next();
-    }
-
-    if (!hasNext) {
-      isDone = true;
-    }
-
-    return {
-      value: incremental.length ? { incremental, hasNext } : { hasNext },
-      done: false,
-    };
-  }
-
-  function returnStreamIterators() {
-    const promises: Array<Promise<IteratorResult<unknown>>> = [];
-    exeContext.subsequentPayloads.forEach((incrementalDataRecord) => {
-      if (
-        isStreamItemsRecord(incrementalDataRecord) &&
-        incrementalDataRecord.asyncIterator?.return
-      ) {
-        promises.push(incrementalDataRecord.asyncIterator.return());
-      }
-    });
-    return Promise.all(promises);
-  }
-
-  return {
-    [Symbol.asyncIterator]() {
-      return this;
-    },
-    next,
-    async return(): Promise<
-      IteratorResult<SubsequentIncrementalExecutionResult, void>
-    > {
-      await returnStreamIterators();
-      isDone = true;
-      return { value: undefined, done: true };
-    },
-    async throw(
-      error?: unknown,
-    ): Promise<IteratorResult<SubsequentIncrementalExecutionResult, void>> {
-      await returnStreamIterators();
-      isDone = true;
-      return Promise.reject(error);
-    },
-  };
-}
-
-class DeferredFragmentRecord {
-  type: 'defer';
-  errors: Array<GraphQLError>;
-  label: string | undefined;
-  path: Array<string | number>;
-  promise: Promise<void>;
-  data: ObjMap<unknown> | null;
-  parentContext: IncrementalDataRecord | undefined;
-  isCompleted: boolean;
-  _exeContext: ExecutionContext;
-  _resolve?: (arg: PromiseOrValue<ObjMap<unknown> | null>) => void;
-  constructor(opts: {
-    label: string | undefined;
-    path: Path | undefined;
-    parentContext: IncrementalDataRecord | undefined;
-    exeContext: ExecutionContext;
-  }) {
-    this.type = 'defer';
-    this.label = opts.label;
-    this.path = pathToArray(opts.path);
-    this.parentContext = opts.parentContext;
-    this.errors = [];
-    this._exeContext = opts.exeContext;
-    this._exeContext.subsequentPayloads.add(this);
-    this.isCompleted = false;
-    this.data = null;
-    this.promise = new Promise<ObjMap<unknown> | null>((resolve) => {
-      this._resolve = (promiseOrValue) => {
-        resolve(promiseOrValue);
-      };
-    }).then((data) => {
-      this.data = data;
-      this.isCompleted = true;
-    });
-  }
-
-  addData(data: PromiseOrValue<ObjMap<unknown> | null>) {
-    const parentData = this.parentContext?.promise;
-    if (parentData) {
-      this._resolve?.(parentData.then(() => data));
-      return;
-    }
-    this._resolve?.(data);
-  }
-}
-
-class StreamItemsRecord {
-  type: 'stream';
-  errors: Array<GraphQLError>;
-  label: string | undefined;
-  path: Array<string | number>;
-  items: Array<unknown> | null;
-  promise: Promise<void>;
-  parentContext: IncrementalDataRecord | undefined;
-  asyncIterator: AsyncIterator<unknown> | undefined;
-  isCompletedAsyncIterator?: boolean;
-  isCompleted: boolean;
-  _exeContext: ExecutionContext;
-  _resolve?: (arg: PromiseOrValue<Array<unknown> | null>) => void;
-  constructor(opts: {
-    label: string | undefined;
-    path: Path | undefined;
-    asyncIterator?: AsyncIterator<unknown>;
-    parentContext: IncrementalDataRecord | undefined;
-    exeContext: ExecutionContext;
-  }) {
-    this.type = 'stream';
-    this.items = null;
-    this.label = opts.label;
-    this.path = pathToArray(opts.path);
-    this.parentContext = opts.parentContext;
-    this.asyncIterator = opts.asyncIterator;
-    this.errors = [];
-    this._exeContext = opts.exeContext;
-    this._exeContext.subsequentPayloads.add(this);
-    this.isCompleted = false;
-    this.items = null;
-    this.promise = new Promise<Array<unknown> | null>((resolve) => {
-      this._resolve = (promiseOrValue) => {
-        resolve(promiseOrValue);
-      };
-    }).then((items) => {
-      this.items = items;
-      this.isCompleted = true;
-    });
-  }
-
-  addItems(items: PromiseOrValue<Array<unknown> | null>) {
-    const parentData = this.parentContext?.promise;
-    if (parentData) {
-      this._resolve?.(parentData.then(() => items));
-      return;
-    }
-    this._resolve?.(items);
-  }
-
-  setIsCompletedAsyncIterator() {
-    this.isCompletedAsyncIterator = true;
-  }
-}
-
-type IncrementalDataRecord = DeferredFragmentRecord | StreamItemsRecord;
-
-function isStreamItemsRecord(
-  incrementalDataRecord: IncrementalDataRecord,
-): incrementalDataRecord is StreamItemsRecord {
-  return incrementalDataRecord.type === 'stream';
-}
diff --git a/src/execution/index.ts b/src/execution/index.ts
index 61b170e1..3c8581c7 100644
--- a/src/execution/index.ts
+++ b/src/execution/index.ts
@@ -15,17 +15,20 @@ export type {
   ExecutionResult,
   ExperimentalIncrementalExecutionResults,
   InitialIncrementalExecutionResult,
+  FormattedExecutionResult,
+  FormattedInitialIncrementalExecutionResult,
+} from './execute.js';
+
+export type {
   SubsequentIncrementalExecutionResult,
   IncrementalDeferResult,
   IncrementalStreamResult,
   IncrementalResult,
-  FormattedExecutionResult,
-  FormattedInitialIncrementalExecutionResult,
   FormattedSubsequentIncrementalExecutionResult,
   FormattedIncrementalDeferResult,
   FormattedIncrementalStreamResult,
   FormattedIncrementalResult,
-} from './execute.js';
+} from './IncrementalPublisher.js';
 
 export {
   getArgumentValues,
diff --git a/src/index.ts b/src/index.ts
index 2817c6eb..1a0f1b4c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -66,6 +66,7 @@ export {
   GraphQLStreamDirective,
   GraphQLDeprecatedDirective,
   GraphQLSpecifiedByDirective,
+  GraphQLOneOfDirective,
   // "Enum" of Type Kinds
   TypeKind,
   // Constant Deprecation Reason
diff --git a/src/jsutils/promiseWithResolvers.ts b/src/jsutils/promiseWithResolvers.ts
new file mode 100644
index 00000000..d5a5eeac
--- /dev/null
+++ b/src/jsutils/promiseWithResolvers.ts
@@ -0,0 +1,20 @@
+import type { PromiseOrValue } from './PromiseOrValue.js';
+
+/**
+ * Based on Promise.withResolvers proposal
+ * https://github.com/tc39/proposal-promise-with-resolvers
+ */
+export function promiseWithResolvers<T>(): {
+  promise: Promise<T>;
+  resolve: (value: T | PromiseOrValue<T>) => void;
+  reject: (reason?: any) => void;
+} {
+  // these are assigned synchronously within the Promise constructor
+  let resolve!: (value: T | PromiseOrValue<T>) => void;
+  let reject!: (reason?: any) => void;
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res;
+    reject = rej;
+  });
+  return { promise, resolve, reject };
+}
diff --git a/src/type/definition.ts b/src/type/definition.ts
index 25f4133a..0ca4152b 100644
--- a/src/type/definition.ts
+++ b/src/type/definition.ts
@@ -1490,6 +1490,7 @@ export class GraphQLInputObjectType {
   extensions: Readonly<GraphQLInputObjectTypeExtensions>;
   astNode: Maybe<InputObjectTypeDefinitionNode>;
   extensionASTNodes: ReadonlyArray<InputObjectTypeExtensionNode>;
+  isOneOf: boolean;
 
   private _fields: ThunkObjMap<GraphQLInputField>;
 
@@ -1499,6 +1500,7 @@ export class GraphQLInputObjectType {
     this.extensions = toObjMap(config.extensions);
     this.astNode = config.astNode;
     this.extensionASTNodes = config.extensionASTNodes ?? [];
+    this.isOneOf = config.isOneOf ?? false;
 
     this._fields = defineInputFieldMap.bind(undefined, config.fields);
   }
@@ -1531,6 +1533,7 @@ export class GraphQLInputObjectType {
       extensions: this.extensions,
       astNode: this.astNode,
       extensionASTNodes: this.extensionASTNodes,
+      isOneOf: this.isOneOf,
     };
   }
 
@@ -1565,6 +1568,7 @@ export interface GraphQLInputObjectTypeConfig {
   extensions?: Maybe<Readonly<GraphQLInputObjectTypeExtensions>>;
   astNode?: Maybe<InputObjectTypeDefinitionNode>;
   extensionASTNodes?: Maybe<ReadonlyArray<InputObjectTypeExtensionNode>>;
+  isOneOf?: boolean;
 }
 
 interface GraphQLInputObjectTypeNormalizedConfig
diff --git a/src/type/directives.ts b/src/type/directives.ts
index 8fd5a6a6..63acf605 100644
--- a/src/type/directives.ts
+++ b/src/type/directives.ts
@@ -247,6 +247,16 @@ export const GraphQLSpecifiedByDirective: GraphQLDirective =
     },
   });
 
+/**
+ * Used to declare an Input Object as a OneOf Input Objects.
+ */
+export const GraphQLOneOfDirective: GraphQLDirective = new GraphQLDirective({
+  name: 'oneOf',
+  description: 'Indicates an Input Object is a OneOf Input Object.',
+  locations: [DirectiveLocation.INPUT_OBJECT],
+  args: {},
+});
+
 /**
  * The full list of specified directives.
  */
@@ -256,6 +266,7 @@ export const specifiedDirectives: ReadonlyArray<GraphQLDirective> =
     GraphQLSkipDirective,
     GraphQLDeprecatedDirective,
     GraphQLSpecifiedByDirective,
+    GraphQLOneOfDirective,
   ]);
 
 export function isSpecifiedDirective(directive: GraphQLDirective): boolean {
diff --git a/src/type/index.ts b/src/type/index.ts
index 2cf94bdf..6617a7bf 100644
--- a/src/type/index.ts
+++ b/src/type/index.ts
@@ -137,6 +137,7 @@ export {
   GraphQLStreamDirective,
   GraphQLDeprecatedDirective,
   GraphQLSpecifiedByDirective,
+  GraphQLOneOfDirective,
   // Constant Deprecation Reason
   DEFAULT_DEPRECATION_REASON,
 } from './directives.js';
diff --git a/src/type/introspection.ts b/src/type/introspection.ts
index f5f59362..5f3c9c1f 100644
--- a/src/type/introspection.ts
+++ b/src/type/introspection.ts
@@ -323,6 +323,14 @@ export const __Type: GraphQLObjectType = new GraphQLObjectType({
         type: __Type,
         resolve: (type) => ('ofType' in type ? type.ofType : undefined),
       },
+      isOneOf: {
+        type: GraphQLBoolean,
+        resolve: (type) => {
+          if (isInputObjectType(type)) {
+            return type.isOneOf;
+          }
+        },
+      },
     } as GraphQLFieldConfigMap<GraphQLType, unknown>),
 });
 
diff --git a/src/type/validate.ts b/src/type/validate.ts
index 86461d5d..deee23a3 100644
--- a/src/type/validate.ts
+++ b/src/type/validate.ts
@@ -542,6 +542,30 @@ function validateInputFields(
         [getDeprecatedDirectiveNode(field.astNode), field.astNode?.type],
       );
     }
+
+    if (inputObj.isOneOf) {
+      validateOneOfInputObjectField(inputObj, field, context);
+    }
+  }
+}
+
+function validateOneOfInputObjectField(
+  type: GraphQLInputObjectType,
+  field: GraphQLInputField,
+  context: SchemaValidationContext,
+): void {
+  if (isNonNullType(field.type)) {
+    context.reportError(
+      `OneOf input field ${type.name}.${field.name} must be nullable.`,
+      field.astNode?.type,
+    );
+  }
+
+  if (field.defaultValue !== undefined) {
+    context.reportError(
+      `OneOf input field ${type.name}.${field.name} cannot have a default value.`,
+      field.astNode,
+    );
   }
 }
 
diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts
index eebddcba..9aa49abe 100644
--- a/src/utilities/coerceInputValue.ts
+++ b/src/utilities/coerceInputValue.ts
@@ -142,6 +142,30 @@ function coerceInputValueImpl(
         );
       }
     }
+
+    if (type.isOneOf) {
+      const keys = Object.keys(coercedValue);
+      if (keys.length !== 1) {
+        onError(
+          pathToArray(path),
+          inputValue,
+          new GraphQLError(
+            `Exactly one key must be specified for OneOf type "${type.name}".`,
+          ),
+        );
+      }
+
+      const key = keys[0];
+      const value = coercedValue[key];
+      if (value === null) {
+        onError(
+          pathToArray(path).concat(key),
+          value,
+          new GraphQLError(`Field "${key}" must be non-null.`),
+        );
+      }
+    }
+
     return coercedValue;
   }
 
diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts
index 831733b6..5a8cba88 100644
--- a/src/utilities/extendSchema.ts
+++ b/src/utilities/extendSchema.ts
@@ -62,6 +62,7 @@ import {
 import {
   GraphQLDeprecatedDirective,
   GraphQLDirective,
+  GraphQLOneOfDirective,
   GraphQLSpecifiedByDirective,
   isSpecifiedDirective,
 } from '../type/directives.js';
@@ -696,6 +697,7 @@ export function extendSchemaImpl(
           fields: () => buildInputFieldMap(allNodes),
           astNode,
           extensionASTNodes,
+          isOneOf: isOneOf(astNode),
         });
       }
     }
@@ -732,3 +734,10 @@ function getSpecifiedByURL(
   // @ts-expect-error validated by `getDirectiveValues`
   return specifiedBy?.url;
 }
+
+/**
+ * Given an input object node, returns if the node should be OneOf.
+ */
+function isOneOf(node: InputObjectTypeDefinitionNode): boolean {
+  return Boolean(getDirectiveValues(GraphQLOneOfDirective, node));
+}
diff --git a/src/utilities/valueFromAST.ts b/src/utilities/valueFromAST.ts
index eeb538d2..5e0d3c51 100644
--- a/src/utilities/valueFromAST.ts
+++ b/src/utilities/valueFromAST.ts
@@ -126,6 +126,18 @@ export function valueFromAST(
       }
       coercedObj[field.name] = fieldValue;
     }
+
+    if (type.isOneOf) {
+      const keys = Object.keys(coercedObj);
+      if (keys.length !== 1) {
+        return; // Invalid: not exactly one key, intentionally return no value.
+      }
+
+      if (coercedObj[keys[0]] === null) {
+        return; // Invalid: value not non-null, intentionally return no value.
+      }
+    }
+
     return coercedObj;
   }
 
diff --git a/src/validation/rules/ValuesOfCorrectTypeRule.ts b/src/validation/rules/ValuesOfCorrectTypeRule.ts
index 09a73167..3b9a1ee6 100644
--- a/src/validation/rules/ValuesOfCorrectTypeRule.ts
+++ b/src/validation/rules/ValuesOfCorrectTypeRule.ts
@@ -4,10 +4,17 @@ import { suggestionList } from '../../jsutils/suggestionList.js';
 
 import { GraphQLError } from '../../error/GraphQLError.js';
 
-import type { ValueNode } from '../../language/ast.js';
+import type {
+  ObjectFieldNode,
+  ObjectValueNode,
+  ValueNode,
+  VariableDefinitionNode,
+} from '../../language/ast.js';
+import { Kind } from '../../language/kinds.js';
 import { print } from '../../language/printer.js';
 import type { ASTVisitor } from '../../language/visitor.js';
 
+import type { GraphQLInputObjectType } from '../../type/definition.js';
 import {
   getNamedType,
   getNullableType,
@@ -31,7 +38,17 @@ import type { ValidationContext } from '../ValidationContext.js';
 export function ValuesOfCorrectTypeRule(
   context: ValidationContext,
 ): ASTVisitor {
+  let variableDefinitions: { [key: string]: VariableDefinitionNode } = {};
+
   return {
+    OperationDefinition: {
+      enter() {
+        variableDefinitions = {};
+      },
+    },
+    VariableDefinition(definition) {
+      variableDefinitions[definition.variable.name.value] = definition;
+    },
     ListValue(node) {
       // Note: TypeInfo will traverse into a list's item type, so look to the
       // parent input type to check if it is a list.
@@ -63,6 +80,16 @@ export function ValuesOfCorrectTypeRule(
           );
         }
       }
+
+      if (type.isOneOf) {
+        validateOneOfInputObject(
+          context,
+          node,
+          type,
+          fieldNodeMap,
+          variableDefinitions,
+        );
+      }
     },
     ObjectField(node) {
       const parentType = getNamedType(context.getParentInputType());
@@ -152,3 +179,52 @@ function isValidValueNode(context: ValidationContext, node: ValueNode): void {
     }
   }
 }
+
+function validateOneOfInputObject(
+  context: ValidationContext,
+  node: ObjectValueNode,
+  type: GraphQLInputObjectType,
+  fieldNodeMap: Map<string, ObjectFieldNode>,
+  variableDefinitions: { [key: string]: VariableDefinitionNode },
+): void {
+  const keys = Array.from(fieldNodeMap.keys());
+  const isNotExactlyOneField = keys.length !== 1;
+
+  if (isNotExactlyOneField) {
+    context.reportError(
+      new GraphQLError(
+        `OneOf Input Object "${type.name}" must specify exactly one key.`,
+        { nodes: [node] },
+      ),
+    );
+    return;
+  }
+
+  const value = fieldNodeMap.get(keys[0])?.value;
+  const isNullLiteral = !value || value.kind === Kind.NULL;
+  const isVariable = value?.kind === Kind.VARIABLE;
+
+  if (isNullLiteral) {
+    context.reportError(
+      new GraphQLError(`Field "${type.name}.${keys[0]}" must be non-null.`, {
+        nodes: [node],
+      }),
+    );
+    return;
+  }
+
+  if (isVariable) {
+    const variableName = value.name.value;
+    const definition = variableDefinitions[variableName];
+    const isNullableVariable = definition.type.kind !== Kind.NON_NULL_TYPE;
+
+    if (isNullableVariable) {
+      context.reportError(
+        new GraphQLError(
+          `Variable "${variableName}" must be non-nullable to be used for OneOf Input Object "${type.name}".`,
+          { nodes: [node] },
+        ),
+      );
+    }
+  }
+}


Test patch:
diff --git a/src/__testUtils__/kitchenSinkSDL.ts b/src/__testUtils__/kitchenSinkSDL.ts
index cdf2f9af..7b7a5377 100644
--- a/src/__testUtils__/kitchenSinkSDL.ts
+++ b/src/__testUtils__/kitchenSinkSDL.ts
@@ -27,6 +27,7 @@ type Foo implements Bar & Baz & Two {
   five(argument: [String] = ["string", "string"]): String
   six(argument: InputType = {key: "value"}): Type
   seven(argument: Int = null): Type
+  eight(argument: OneOfInputType): Type
 }
 
 type AnnotatedObject @onObject(arg: "value") {
@@ -116,6 +117,11 @@ input InputType {
   answer: Int = 42
 }
 
+input OneOfInputType @oneOf {
+  string: String
+  int: Int
+}
+
 input AnnotatedInput @onInputObject {
   annotatedField: Type @onInputFieldDefinition
 }
diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts
index 31ddf9e6..e2f8834c 100644
--- a/src/execution/__tests__/defer-test.ts
+++ b/src/execution/__tests__/defer-test.ts
@@ -16,11 +16,9 @@ import {
 import { GraphQLID, GraphQLString } from '../../type/scalars.js';
 import { GraphQLSchema } from '../../type/schema.js';
 
-import type {
-  InitialIncrementalExecutionResult,
-  SubsequentIncrementalExecutionResult,
-} from '../execute.js';
+import type { InitialIncrementalExecutionResult } from '../execute.js';
 import { execute, experimentalExecuteIncrementally } from '../execute.js';
+import type { SubsequentIncrementalExecutionResult } from '../IncrementalPublisher.js';
 
 const friendType = new GraphQLObjectType({
   fields: {
diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts
index 2de2d989..ecaf4bb1 100644
--- a/src/execution/__tests__/lists-test.ts
+++ b/src/execution/__tests__/lists-test.ts
@@ -141,12 +141,12 @@ describe('Execute: Accepts async iterables as list value', () => {
     }
 
     expectJSON(await complete({ listField })).toDeepEqual({
-      data: { listField: ['two', '4', null] },
+      data: { listField: null },
       errors: [
         {
           message: 'bad',
           locations: [{ line: 1, column: 3 }],
-          path: ['listField', 2],
+          path: ['listField'],
         },
       ],
     });
diff --git a/src/execution/__tests__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts
new file mode 100644
index 00000000..acde6031
--- /dev/null
+++ b/src/execution/__tests__/oneof-test.ts
@@ -0,0 +1,185 @@
+import { describe, it } from 'mocha';
+
+import { expectJSON } from '../../__testUtils__/expectJSON.js';
+
+import { parse } from '../../language/parser.js';
+
+import { buildSchema } from '../../utilities/buildASTSchema.js';
+
+import type { ExecutionResult } from '../execute.js';
+import { execute } from '../execute.js';
+
+const schema = buildSchema(`
+  type Query {
+    test(input: TestInputObject!): TestObject
+  }
+
+  input TestInputObject @oneOf {
+    a: String
+    b: Int
+  }
+
+  type TestObject {
+    a: String
+    b: Int
+  }
+`);
+
+function executeQuery(
+  query: string,
+  rootValue: unknown,
+  variableValues?: { [variable: string]: unknown },
+): ExecutionResult | Promise<ExecutionResult> {
+  return execute({ schema, document: parse(query), rootValue, variableValues });
+}
+
+describe('Execute: Handles OneOf Input Objects', () => {
+  describe('OneOf Input Objects', () => {
+    const rootValue = {
+      test({ input }: { input: { a?: string; b?: number } }) {
+        return input;
+      },
+    };
+
+    it('accepts a good default value', () => {
+      const query = `
+        query ($input: TestInputObject! = {a: "abc"}) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue);
+
+      expectJSON(result).toDeepEqual({
+        data: {
+          test: {
+            a: 'abc',
+            b: null,
+          },
+        },
+      });
+    });
+
+    it('rejects a bad default value', () => {
+      const query = `
+        query ($input: TestInputObject! = {a: "abc", b: 123}) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue);
+
+      expectJSON(result).toDeepEqual({
+        data: {
+          test: null,
+        },
+        errors: [
+          {
+            locations: [{ column: 23, line: 3 }],
+            message:
+              // This type of error would be caught at validation-time
+              // hence the vague error message here.
+              'Argument "input" of non-null type "TestInputObject!" must not be null.',
+            path: ['test'],
+          },
+        ],
+      });
+    });
+
+    it('accepts a good variable', () => {
+      const query = `
+        query ($input: TestInputObject!) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue, { input: { a: 'abc' } });
+
+      expectJSON(result).toDeepEqual({
+        data: {
+          test: {
+            a: 'abc',
+            b: null,
+          },
+        },
+      });
+    });
+
+    it('accepts a good variable with an undefined key', () => {
+      const query = `
+        query ($input: TestInputObject!) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue, {
+        input: { a: 'abc', b: undefined },
+      });
+
+      expectJSON(result).toDeepEqual({
+        data: {
+          test: {
+            a: 'abc',
+            b: null,
+          },
+        },
+      });
+    });
+
+    it('rejects a variable with multiple non-null keys', () => {
+      const query = `
+        query ($input: TestInputObject!) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue, {
+        input: { a: 'abc', b: 123 },
+      });
+
+      expectJSON(result).toDeepEqual({
+        errors: [
+          {
+            locations: [{ column: 16, line: 2 }],
+            message:
+              'Variable "$input" got invalid value { a: "abc", b: 123 }; Exactly one key must be specified for OneOf type "TestInputObject".',
+          },
+        ],
+      });
+    });
+
+    it('rejects a variable with multiple nullable keys', () => {
+      const query = `
+        query ($input: TestInputObject!) {
+          test(input: $input) {
+            a
+            b
+          }
+        }
+      `;
+      const result = executeQuery(query, rootValue, {
+        input: { a: 'abc', b: null },
+      });
+
+      expectJSON(result).toDeepEqual({
+        errors: [
+          {
+            locations: [{ column: 16, line: 2 }],
+            message:
+              'Variable "$input" got invalid value { a: "abc", b: null }; Exactly one key must be specified for OneOf type "TestInputObject".',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts
index 2b9ad827..ce3b9208 100644
--- a/src/execution/__tests__/stream-test.ts
+++ b/src/execution/__tests__/stream-test.ts
@@ -4,6 +4,7 @@ import { describe, it } from 'mocha';
 import { expectJSON } from '../../__testUtils__/expectJSON.js';
 
 import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
+import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
 
 import type { DocumentNode } from '../../language/ast.js';
 import { parse } from '../../language/parser.js';
@@ -16,11 +17,9 @@ import {
 import { GraphQLID, GraphQLString } from '../../type/scalars.js';
 import { GraphQLSchema } from '../../type/schema.js';
 
-import type {
-  InitialIncrementalExecutionResult,
-  SubsequentIncrementalExecutionResult,
-} from '../execute.js';
+import type { InitialIncrementalExecutionResult } from '../execute.js';
 import { experimentalExecuteIncrementally } from '../execute.js';
+import type { SubsequentIncrementalExecutionResult } from '../IncrementalPublisher.js';
 
 const friendType = new GraphQLObjectType({
   fields: {
@@ -129,14 +128,6 @@ async function completeAsync(
   return Promise.all(promises);
 }
 
-function createResolvablePromise<T>(): [Promise<T>, (value?: T) => void] {
-  let resolveFn;
-  const promise = new Promise<T>((resolve) => {
-    resolveFn = resolve;
-  });
-  return [promise, resolveFn as unknown as (value?: T) => void];
-}
-
 describe('Execute: stream directive', () => {
   it('Can stream a list field', async () => {
     const document = parse('{ scalarList @stream(initialCount: 1) }');
@@ -731,11 +722,11 @@ describe('Execute: stream directive', () => {
         {
           message: 'bad',
           locations: [{ line: 3, column: 9 }],
-          path: ['friendList', 1],
+          path: ['friendList'],
         },
       ],
       data: {
-        friendList: [{ name: 'Luke', id: '1' }, null],
+        friendList: null,
       },
     });
   });
@@ -764,13 +755,13 @@ describe('Execute: stream directive', () => {
       {
         incremental: [
           {
-            items: [null],
+            items: null,
             path: ['friendList', 1],
             errors: [
               {
                 message: 'bad',
                 locations: [{ line: 3, column: 9 }],
-                path: ['friendList', 1],
+                path: ['friendList'],
               },
             ],
           },
@@ -1174,9 +1165,6 @@ describe('Execute: stream directive', () => {
             ],
           },
         ],
-        hasNext: true,
-      },
-      {
         hasNext: false,
       },
     ]);
@@ -1200,25 +1188,19 @@ describe('Execute: stream directive', () => {
         } /* c8 ignore stop */,
       },
     });
-    expectJSON(result).toDeepEqual([
-      {
-        errors: [
-          {
-            message:
-              'Cannot return null for non-nullable field NestedObject.nonNullScalarField.',
-            locations: [{ line: 4, column: 11 }],
-            path: ['nestedObject', 'nonNullScalarField'],
-          },
-        ],
-        data: {
-          nestedObject: null,
+    expectJSON(result).toDeepEqual({
+      errors: [
+        {
+          message:
+            'Cannot return null for non-nullable field NestedObject.nonNullScalarField.',
+          locations: [{ line: 4, column: 11 }],
+          path: ['nestedObject', 'nonNullScalarField'],
         },
-        hasNext: true,
-      },
-      {
-        hasNext: false,
+      ],
+      data: {
+        nestedObject: null,
       },
-    ]);
+    });
   });
   it('Filters payloads that are nulled by a later synchronous error', async () => {
     const document = parse(`
@@ -1302,6 +1284,9 @@ describe('Execute: stream directive', () => {
             path: ['nestedObject', 'nestedFriendList', 0],
           },
         ],
+        hasNext: true,
+      },
+      {
         hasNext: false,
       },
     ]);
@@ -1359,9 +1344,6 @@ describe('Execute: stream directive', () => {
             ],
           },
         ],
-        hasNext: true,
-      },
-      {
         hasNext: false,
       },
     ]);
@@ -1564,7 +1546,8 @@ describe('Execute: stream directive', () => {
     ]);
   });
   it('Returns payloads in correct order when parent deferred fragment resolves slower than stream', async () => {
-    const [slowFieldPromise, resolveSlowField] = createResolvablePromise();
+    const { promise: slowFieldPromise, resolve: resolveSlowField } =
+      promiseWithResolvers();
     const document = parse(`
       query { 
         nestedObject {
@@ -1655,9 +1638,12 @@ describe('Execute: stream directive', () => {
     });
   });
   it('Can @defer fields that are resolved after async iterable is complete', async () => {
-    const [slowFieldPromise, resolveSlowField] = createResolvablePromise();
-    const [iterableCompletionPromise, resolveIterableCompletion] =
-      createResolvablePromise();
+    const { promise: slowFieldPromise, resolve: resolveSlowField } =
+      promiseWithResolvers();
+    const {
+      promise: iterableCompletionPromise,
+      resolve: resolveIterableCompletion,
+    } = promiseWithResolvers();
 
     const document = parse(`
     query { 
@@ -1697,7 +1683,7 @@ describe('Execute: stream directive', () => {
     });
 
     const result2Promise = iterator.next();
-    resolveIterableCompletion();
+    resolveIterableCompletion(null);
     const result2 = await result2Promise;
     expectJSON(result2).toDeepEqual({
       value: {
@@ -1741,9 +1727,12 @@ describe('Execute: stream directive', () => {
     });
   });
   it('Can @defer fields that are resolved before async iterable is complete', async () => {
-    const [slowFieldPromise, resolveSlowField] = createResolvablePromise();
-    const [iterableCompletionPromise, resolveIterableCompletion] =
-      createResolvablePromise();
+    const { promise: slowFieldPromise, resolve: resolveSlowField } =
+      promiseWithResolvers();
+    const {
+      promise: iterableCompletionPromise,
+      resolve: resolveIterableCompletion,
+    } = promiseWithResolvers();
 
     const document = parse(`
     query { 
@@ -1819,7 +1808,7 @@ describe('Execute: stream directive', () => {
       done: false,
     });
     const result4Promise = iterator.next();
-    resolveIterableCompletion();
+    resolveIterableCompletion(null);
     const result4 = await result4Promise;
     expectJSON(result4).toDeepEqual({
       value: { hasNext: false },
diff --git a/src/jsutils/__tests__/promiseWithResolvers-test.ts b/src/jsutils/__tests__/promiseWithResolvers-test.ts
new file mode 100644
index 00000000..453a6fb6
--- /dev/null
+++ b/src/jsutils/__tests__/promiseWithResolvers-test.ts
@@ -0,0 +1,21 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import { expectPromise } from '../../__testUtils__/expectPromise.js';
+
+import { promiseWithResolvers } from '../promiseWithResolvers.js';
+
+describe('promiseWithResolvers', () => {
+  it('resolves values', async () => {
+    const { promise, resolve } = promiseWithResolvers();
+    resolve('foo');
+    expect(await expectPromise(promise).toResolve()).to.equal('foo');
+  });
+
+  it('rejects values', async () => {
+    const { promise, reject } = promiseWithResolvers();
+    const error = new Error('rejected');
+    reject(error);
+    await expectPromise(promise).toRejectWith('rejected');
+  });
+});
diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts
index 3de9865c..4f8166b7 100644
--- a/src/language/__tests__/schema-printer-test.ts
+++ b/src/language/__tests__/schema-printer-test.ts
@@ -61,6 +61,7 @@ describe('Printer: SDL document', () => {
         five(argument: [String] = ["string", "string"]): String
         six(argument: InputType = { key: "value" }): Type
         seven(argument: Int = null): Type
+        eight(argument: OneOfInputType): Type
       }
 
       type AnnotatedObject @onObject(arg: "value") {
@@ -143,6 +144,11 @@ describe('Printer: SDL document', () => {
         answer: Int = 42
       }
 
+      input OneOfInputType @oneOf {
+        string: String
+        int: Int
+      }
+
       input AnnotatedInput @onInputObject {
         annotatedField: Type @onInputFieldDefinition
       }
diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts
index 1fa0518d..e6e5d094 100644
--- a/src/type/__tests__/introspection-test.ts
+++ b/src/type/__tests__/introspection-test.ts
@@ -372,6 +372,17 @@ describe('Introspection', () => {
                   isDeprecated: false,
                   deprecationReason: null,
                 },
+                {
+                  name: 'isOneOf',
+                  args: [],
+                  type: {
+                    kind: 'SCALAR',
+                    name: 'Boolean',
+                    ofType: null,
+                  },
+                  isDeprecated: false,
+                  deprecationReason: null,
+                },
               ],
               inputFields: null,
               interfaces: [],
@@ -989,6 +1000,12 @@ describe('Introspection', () => {
                 },
               ],
             },
+            {
+              name: 'oneOf',
+              isRepeatable: false,
+              locations: ['INPUT_OBJECT'],
+              args: [],
+            },
           ],
         },
       },
@@ -1519,6 +1536,95 @@ describe('Introspection', () => {
     });
   });
 
+  it('identifies oneOf for input objects', () => {
+    const schema = buildSchema(`
+      input SomeInputObject @oneOf {
+        a: String
+      }
+
+      input AnotherInputObject {
+        a: String
+        b: String
+      }
+
+      type Query {
+        someField(someArg: SomeInputObject): String
+        anotherField(anotherArg: AnotherInputObject): String
+      }
+    `);
+
+    const source = `
+      {
+        oneOfInputObject: __type(name: "SomeInputObject") {
+          isOneOf
+        }
+        inputObject: __type(name: "AnotherInputObject") {
+          isOneOf
+        }
+      }
+    `;
+
+    expect(graphqlSync({ schema, source })).to.deep.equal({
+      data: {
+        oneOfInputObject: {
+          isOneOf: true,
+        },
+        inputObject: {
+          isOneOf: false,
+        },
+      },
+    });
+  });
+
+  it('returns null for oneOf for other types', () => {
+    const schema = buildSchema(`
+      type SomeObject implements SomeInterface {
+        fieldA: String
+      }
+      enum SomeEnum {
+        SomeObject
+      }
+      interface SomeInterface {
+        fieldA: String
+      }
+      union SomeUnion = SomeObject
+      type Query {
+        someField(enum: SomeEnum): SomeUnion
+        anotherField(enum: SomeEnum): SomeInterface
+      }
+    `);
+
+    const source = `
+      {
+        object: __type(name: "SomeObject") {
+          isOneOf
+        }
+        enum: __type(name: "SomeEnum") {
+          isOneOf
+        }
+        interface: __type(name: "SomeInterface") {
+          isOneOf
+        }
+        scalar: __type(name: "String") {
+          isOneOf
+        }
+        union: __type(name: "SomeUnion") {
+          isOneOf
+        }
+      }
+    `;
+
+    expect(graphqlSync({ schema, source })).to.deep.equal({
+      data: {
+        object: { isOneOf: null },
+        enum: { isOneOf: null },
+        interface: { isOneOf: null },
+        scalar: { isOneOf: null },
+        union: { isOneOf: null },
+      },
+    });
+  });
+
   it('fails as expected on the __type root field without an arg', () => {
     const schema = buildSchema(`
       type Query {
diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts
index a147a7e8..7d215361 100644
--- a/src/type/__tests__/validation-test.ts
+++ b/src/type/__tests__/validation-test.ts
@@ -336,7 +336,7 @@ describe('Type System: A Schema must have Object root types', () => {
     ]);
   });
 
-  it('rejects a schema extended with invalid root types', () => {
+  it('rejects a Schema extended with invalid root types', () => {
     let schema = buildSchema(`
       input SomeInputObject {
         test: String
@@ -1749,6 +1749,47 @@ describe('Type System: Input Object fields must have input types', () => {
   });
 });
 
+describe('Type System: OneOf Input Object fields must be nullable', () => {
+  it('rejects non-nullable fields', () => {
+    const schema = buildSchema(`
+      type Query {
+        test(arg: SomeInputObject): String
+      }
+
+      input SomeInputObject @oneOf {
+        a: String
+        b: String!
+      }
+    `);
+    expectJSON(validateSchema(schema)).toDeepEqual([
+      {
+        message: 'OneOf input field SomeInputObject.b must be nullable.',
+        locations: [{ line: 8, column: 12 }],
+      },
+    ]);
+  });
+
+  it('rejects fields with default values', () => {
+    const schema = buildSchema(`
+      type Query {
+        test(arg: SomeInputObject): String
+      }
+
+      input SomeInputObject @oneOf {
+        a: String
+        b: String = "foo"
+      }
+    `);
+    expectJSON(validateSchema(schema)).toDeepEqual([
+      {
+        message:
+          'OneOf input field SomeInputObject.b cannot have a default value.',
+        locations: [{ line: 8, column: 9 }],
+      },
+    ]);
+  });
+});
+
 describe('Objects must adhere to Interface they implement', () => {
   it('accepts an Object which implements an Interface', () => {
     const schema = buildSchema(`
diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts
index 6e152af3..e199abc2 100644
--- a/src/utilities/__tests__/buildASTSchema-test.ts
+++ b/src/utilities/__tests__/buildASTSchema-test.ts
@@ -23,6 +23,7 @@ import {
   assertDirective,
   GraphQLDeprecatedDirective,
   GraphQLIncludeDirective,
+  GraphQLOneOfDirective,
   GraphQLSkipDirective,
   GraphQLSpecifiedByDirective,
 } from '../../type/directives.js';
@@ -222,7 +223,7 @@ describe('Schema Builder', () => {
   it('Maintains @include, @skip & @specifiedBy', () => {
     const schema = buildSchema('type Query');
 
-    expect(schema.getDirectives()).to.have.lengthOf(4);
+    expect(schema.getDirectives()).to.have.lengthOf(5);
     expect(schema.getDirective('skip')).to.equal(GraphQLSkipDirective);
     expect(schema.getDirective('include')).to.equal(GraphQLIncludeDirective);
     expect(schema.getDirective('deprecated')).to.equal(
@@ -231,6 +232,7 @@ describe('Schema Builder', () => {
     expect(schema.getDirective('specifiedBy')).to.equal(
       GraphQLSpecifiedByDirective,
     );
+    expect(schema.getDirective('oneOf')).to.equal(GraphQLOneOfDirective);
   });
 
   it('Overriding directives excludes specified', () => {
@@ -239,9 +241,10 @@ describe('Schema Builder', () => {
       directive @include on FIELD
       directive @deprecated on FIELD_DEFINITION
       directive @specifiedBy on FIELD_DEFINITION
+      directive @oneOf on OBJECT
     `);
 
-    expect(schema.getDirectives()).to.have.lengthOf(4);
+    expect(schema.getDirectives()).to.have.lengthOf(5);
     expect(schema.getDirective('skip')).to.not.equal(GraphQLSkipDirective);
     expect(schema.getDirective('include')).to.not.equal(
       GraphQLIncludeDirective,
@@ -252,18 +255,20 @@ describe('Schema Builder', () => {
     expect(schema.getDirective('specifiedBy')).to.not.equal(
       GraphQLSpecifiedByDirective,
     );
+    expect(schema.getDirective('oneOf')).to.not.equal(GraphQLOneOfDirective);
   });
 
-  it('Adding directives maintains @include, @skip & @specifiedBy', () => {
+  it('Adding directives maintains @include, @skip, @deprecated, @specifiedBy, and @oneOf', () => {
     const schema = buildSchema(`
       directive @foo(arg: Int) on FIELD
     `);
 
-    expect(schema.getDirectives()).to.have.lengthOf(5);
+    expect(schema.getDirectives()).to.have.lengthOf(6);
     expect(schema.getDirective('skip')).to.not.equal(undefined);
     expect(schema.getDirective('include')).to.not.equal(undefined);
     expect(schema.getDirective('deprecated')).to.not.equal(undefined);
     expect(schema.getDirective('specifiedBy')).to.not.equal(undefined);
+    expect(schema.getDirective('oneOf')).to.not.equal(undefined);
   });
 
   it('Type modifiers', () => {
diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts
index 5e0199fe..7712c91a 100644
--- a/src/utilities/__tests__/coerceInputValue-test.ts
+++ b/src/utilities/__tests__/coerceInputValue-test.ts
@@ -273,6 +273,111 @@ describe('coerceInputValue', () => {
     });
   });
 
+  describe('for GraphQLInputObject that isOneOf', () => {
+    const TestInputObject = new GraphQLInputObjectType({
+      name: 'TestInputObject',
+      fields: {
+        foo: { type: GraphQLInt },
+        bar: { type: GraphQLInt },
+      },
+      isOneOf: true,
+    });
+
+    it('returns no error for a valid input', () => {
+      const result = coerceValue({ foo: 123 }, TestInputObject);
+      expectValue(result).to.deep.equal({ foo: 123 });
+    });
+
+    it('returns an error if more than one field is specified', () => {
+      const result = coerceValue({ foo: 123, bar: null }, TestInputObject);
+      expectErrors(result).to.deep.equal([
+        {
+          error:
+            'Exactly one key must be specified for OneOf type "TestInputObject".',
+          path: [],
+          value: { foo: 123, bar: null },
+        },
+      ]);
+    });
+
+    it('returns an error the one field is null', () => {
+      const result = coerceValue({ bar: null }, TestInputObject);
+      expectErrors(result).to.deep.equal([
+        {
+          error: 'Field "bar" must be non-null.',
+          path: ['bar'],
+          value: null,
+        },
+      ]);
+    });
+
+    it('returns an error for an invalid field', () => {
+      const result = coerceValue({ foo: NaN }, TestInputObject);
+      expectErrors(result).to.deep.equal([
+        {
+          error: 'Int cannot represent non-integer value: NaN',
+          path: ['foo'],
+          value: NaN,
+        },
+      ]);
+    });
+
+    it('returns multiple errors for multiple invalid fields', () => {
+      const result = coerceValue({ foo: 'abc', bar: 'def' }, TestInputObject);
+      expectErrors(result).to.deep.equal([
+        {
+          error: 'Int cannot represent non-integer value: "abc"',
+          path: ['foo'],
+          value: 'abc',
+        },
+        {
+          error: 'Int cannot represent non-integer value: "def"',
+          path: ['bar'],
+          value: 'def',
+        },
+        {
+          error:
+            'Exactly one key must be specified for OneOf type "TestInputObject".',
+          path: [],
+          value: { foo: 'abc', bar: 'def' },
+        },
+      ]);
+    });
+
+    it('returns error for an unknown field', () => {
+      const result = coerceValue(
+        { foo: 123, unknownField: 123 },
+        TestInputObject,
+      );
+      expectErrors(result).to.deep.equal([
+        {
+          error:
+            'Field "unknownField" is not defined by type "TestInputObject".',
+          path: [],
+          value: { foo: 123, unknownField: 123 },
+        },
+      ]);
+    });
+
+    it('returns error for a misspelled field', () => {
+      const result = coerceValue({ bart: 123 }, TestInputObject);
+      expectErrors(result).to.deep.equal([
+        {
+          error:
+            'Field "bart" is not defined by type "TestInputObject". Did you mean "bar"?',
+          path: [],
+          value: { bart: 123 },
+        },
+        {
+          error:
+            'Exactly one key must be specified for OneOf type "TestInputObject".',
+          path: [],
+          value: { bart: 123 },
+        },
+      ]);
+    });
+  });
+
   describe('for GraphQLInputObject with default value', () => {
     const makeTestInputObject = (defaultValue: any) =>
       new GraphQLInputObjectType({
diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts
index dbd19cb8..5d92522f 100644
--- a/src/utilities/__tests__/findBreakingChanges-test.ts
+++ b/src/utilities/__tests__/findBreakingChanges-test.ts
@@ -4,6 +4,7 @@ import { describe, it } from 'mocha';
 import {
   GraphQLDeprecatedDirective,
   GraphQLIncludeDirective,
+  GraphQLOneOfDirective,
   GraphQLSkipDirective,
   GraphQLSpecifiedByDirective,
 } from '../../type/directives.js';
@@ -802,6 +803,7 @@ describe('findBreakingChanges', () => {
         GraphQLSkipDirective,
         GraphQLIncludeDirective,
         GraphQLSpecifiedByDirective,
+        GraphQLOneOfDirective,
       ],
     });
 
diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts
index 2da58e7d..8940cafb 100644
--- a/src/utilities/__tests__/printSchema-test.ts
+++ b/src/utilities/__tests__/printSchema-test.ts
@@ -795,6 +795,9 @@ describe('Type System Printer', () => {
         url: String!
       ) on SCALAR
 
+      """Indicates an Input Object is a OneOf Input Object."""
+      directive @oneOf on INPUT_OBJECT
+
       """
       A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.
       """
@@ -837,6 +840,7 @@ describe('Type System Printer', () => {
         enumValues(includeDeprecated: Boolean = false): [__EnumValue!]
         inputFields(includeDeprecated: Boolean = false): [__InputValue!]
         ofType: __Type
+        isOneOf: Boolean
       }
 
       """An enum describing what kind of type a given \`__Type\` is."""
diff --git a/src/utilities/__tests__/valueFromAST-test.ts b/src/utilities/__tests__/valueFromAST-test.ts
index d0a8380d..e287d916 100644
--- a/src/utilities/__tests__/valueFromAST-test.ts
+++ b/src/utilities/__tests__/valueFromAST-test.ts
@@ -195,6 +195,14 @@ describe('valueFromAST', () => {
       requiredBool: { type: nonNullBool },
     },
   });
+  const testOneOfInputObj = new GraphQLInputObjectType({
+    name: 'TestOneOfInput',
+    fields: {
+      a: { type: GraphQLString },
+      b: { type: GraphQLString },
+    },
+    isOneOf: true,
+  });
 
   it('coerces input objects according to input coercion rules', () => {
     expectValueFrom('null', testInputObj).to.equal(null);
@@ -220,6 +228,22 @@ describe('valueFromAST', () => {
     );
     expectValueFrom('{ requiredBool: null }', testInputObj).to.equal(undefined);
     expectValueFrom('{ bool: true }', testInputObj).to.equal(undefined);
+    expectValueFrom('{ a: "abc" }', testOneOfInputObj).to.deep.equal({
+      a: 'abc',
+    });
+    expectValueFrom('{ b: "def" }', testOneOfInputObj).to.deep.equal({
+      b: 'def',
+    });
+    expectValueFrom('{ a: "abc", b: null }', testOneOfInputObj).to.deep.equal(
+      undefined,
+    );
+    expectValueFrom('{ a: null }', testOneOfInputObj).to.equal(undefined);
+    expectValueFrom('{ a: 1 }', testOneOfInputObj).to.equal(undefined);
+    expectValueFrom('{ a: "abc", b: "def" }', testOneOfInputObj).to.equal(
+      undefined,
+    );
+    expectValueFrom('{}', testOneOfInputObj).to.equal(undefined);
+    expectValueFrom('{ c: "abc" }', testOneOfInputObj).to.equal(undefined);
   });
 
   it('accepts variable values assuming already coerced', () => {
diff --git a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts
index f0b7dfa5..c36ebb69 100644
--- a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts
+++ b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts
@@ -874,6 +874,28 @@ describe('Validate: Values of correct type', () => {
     });
   });
 
+  describe('Valid oneOf input object value', () => {
+    it('Exactly one field', () => {
+      expectValid(`
+        {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: "abc" })
+          }
+        }
+      `);
+    });
+
+    it('Exactly one non-nullable variable', () => {
+      expectValid(`
+        query ($string: String!) {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: $string })
+          }
+        }
+      `);
+    });
+  });
+
   describe('Invalid input object value', () => {
     it('Partial object, missing required', () => {
       expectErrors(`
@@ -1041,6 +1063,70 @@ describe('Validate: Values of correct type', () => {
     });
   });
 
+  describe('Invalid oneOf input object value', () => {
+    it('Invalid field type', () => {
+      expectErrors(`
+        {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: 2 })
+          }
+        }
+      `).toDeepEqual([
+        {
+          message: 'String cannot represent a non string value: 2',
+          locations: [{ line: 4, column: 52 }],
+        },
+      ]);
+    });
+
+    it('Exactly one null field', () => {
+      expectErrors(`
+        {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: null })
+          }
+        }
+      `).toDeepEqual([
+        {
+          message: 'Field "OneOfInput.stringField" must be non-null.',
+          locations: [{ line: 4, column: 37 }],
+        },
+      ]);
+    });
+
+    it('Exactly one nullable variable', () => {
+      expectErrors(`
+        query ($string: String) {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: $string })
+          }
+        }
+      `).toDeepEqual([
+        {
+          message:
+            'Variable "string" must be non-nullable to be used for OneOf Input Object "OneOfInput".',
+          locations: [{ line: 4, column: 37 }],
+        },
+      ]);
+    });
+
+    it('More than one field', () => {
+      expectErrors(`
+        {
+          complicatedArgs {
+            oneOfArgField(oneOfArg: { stringField: "abc", intField: 123 })
+          }
+        }
+      `).toDeepEqual([
+        {
+          message:
+            'OneOf Input Object "OneOfInput" must specify exactly one key.',
+          locations: [{ line: 4, column: 37 }],
+        },
+      ]);
+    });
+  });
+
   describe('Directive arguments', () => {
     it('with directives of valid types', () => {
       expectValid(`
diff --git a/src/validation/__tests__/harness.ts b/src/validation/__tests__/harness.ts
index 682932d8..b7710ff9 100644
--- a/src/validation/__tests__/harness.ts
+++ b/src/validation/__tests__/harness.ts
@@ -82,6 +82,11 @@ export const testSchema: GraphQLSchema = buildSchema(`
     stringListField: [String]
   }
 
+  input OneOfInput @oneOf {
+    stringField: String
+    intField: Int
+  }
+
   type ComplicatedArgs {
     # TODO List
     # TODO Coercion
@@ -96,6 +101,7 @@ export const testSchema: GraphQLSchema = buildSchema(`
     stringListArgField(stringListArg: [String]): String
     stringListNonNullArgField(stringListNonNullArg: [String!]): String
     complexArgField(complexArg: ComplexInput): String
+    oneOfArgField(oneOfArg: OneOfInput): String
     multipleReqs(req1: Int!, req2: Int!): String
     nonNullFieldWithDefault(arg: Int! = 0): String
     multipleOpts(opt1: Int = 0, opt2: Int = 0): String