Skip to content

Merging behaviour

Inputs are processed sequentially: the first input in the list takes precedence, and subsequent inputs are modified to merge seamlessly into it. This section is the reference for exactly what that means, field by field, and for every way merge() can fail.

What's actually merged vs. first-wins

For paths, components, webhooks and tags, the library merges the definitions from every input, so there's no overlap and no information is dropped (subject to the conflict rules below).

For most other top-level elements, the default is that the first input that declares it wins, and later inputs' values are discarded:

ElementDefaultConfigurable?
infoFirst inputField-by-field override via MergeOptions.info
serversFirst input with anyMergeOptions.serversStrategy: 'concat' keeps every input's servers instead
externalDocsFirst input with anyNot configurable
components.securitySchemesMerged (not first-wins)MergeOptions.securitySchemesStrategy can select 'first' or 'error' instead
A document-root x-* extensionFirst input that declares itMergeOptions.extensionMergeStrategies, per extension key

Security schemes are the one component bucket that does not default to first-wins the way info/servers/ externalDocs do — see Merge options → securitySchemesStrategy for why the default changed. x-* extensions are first-wins the same way info/servers/externalDocs are, but unlike those three, the alternative isn't a single fixed strategy — see Merge options → extensionMergeStrategies for the full, per-extension configuration this one supports.

The intent behind first-wins-by-default: this library is aimed at putting several services behind one API gateway, where the gateway's own info/servers/externalDocs are canonical and a backend's own values are an implementation detail that shouldn't leak into the published document.

$self (OpenAPI 3.1+)

$self declares a document's own identity. A merged document is not any one of its inputs, so carrying one input's $self forward would assert an identity the output doesn't have — and because $self participates in reference resolution, a stale value could change how relative $refs resolve. It's kept only in the degenerate single-input case, where the output really is that document, and dropped otherwise.

Deduplication

Components (schemas, parameters, responses, etc.) that are structurally identical across inputs are collapsed into one. Components that share a name but differ are renamed using that input's dispute setting (prefix or suffix), or reported as a component-definition-conflict error if no dispute is configured. operationIds follow the same pattern: a genuine collision that no dispute can resolve is an operation-id-conflict.

Error types

Every failure merge() can return comes back as { type: ErrorType, message: string }:

typeWhen it fires
no-inputsinputs was empty.
duplicate-pathsTwo inputs declared the same path (or webhook) and duplicatePathHandling didn't resolve it.
component-definition-conflictTwo inputs defined a component with the same name but different content, and no dispute resolved it.
operation-id-conflictTwo operations ended up with the same operationId and no dispute resolved it.
unsupported-openapi-versionAn input declared a version this library can't merge, or none at all.
mixed-openapi-versionsThe inputs didn't all declare the same major.minor OpenAPI version.
duplicate-webhooksTwo inputs declared the same webhook name (3.1).
cyclic-external-referenceA component reachable from MergeOptions.externalDocuments transitively references itself.
malformed-documentA null sits in a slot the spec requires to be an object (or, for a discriminator mapping target, a string) — e.g. schemas: { Widget: }, an empty YAML value.
extension-merge-conflictTwo or more inputs disagreed on the value of an x-* extension (or a nested field/element within it) that extensionMergeStrategies configured with the 'error' strategy at that point. The message names the exact path inside the extension's value where the disagreement was found.

Thrown vs. returned errors

Almost everything above is returned as an ErrorMergeResultmerge() does not throw for a malformed merge. There is one deliberate exception: malformed-document is detected deep inside a recursive reference-walk with no error-return type threaded through dozens of intermediate call sites, so it's thrown as MalformedDocumentError from wherever it's found, and caught narrowly at the top of merge() — converted into the malformed-document result before it ever reaches your code (issue #92).

MalformedDocumentError is exported from the package root in case you need to recognise it in a context that bypasses merge()'s own catch (it shouldn't, in normal use). Anything else thrown out of merge() — an internal invariant violation, like "more than one matching key" during deduplication — represents a real bug in this library, not a merge failure to handle, and is intentionally left to propagate rather than swallowed.

Example: handling every error type

ts
import { merge, isErrorResult } from 'openapi-merge';

const result = merge(inputs);

if (isErrorResult(result)) {
  switch (result.type) {
    case 'duplicate-paths':
    case 'component-definition-conflict':
    case 'operation-id-conflict':
      // Configuration problem: adjust `dispute` / `duplicatePathHandling` and retry.
      break;
    case 'unsupported-openapi-version':
    case 'mixed-openapi-versions':
      // The inputs themselves need to change (convert or align versions).
      break;
    default:
      console.error(result.message);
  }
}

Released under the MIT License.