A parser that hands back generic objects and arrays only gets you halfway. The moment your tool reaches for an operation, a schema, or a server URL, it's back to matching strings against keys — the work the parser refused to do.

ApiDOM hands back something different: a tree that already knows what every piece of it represents. An operation is an OperationElement. A schema is a SchemaElement. A server URL isn't just a string — it's a string tagged as a URL, in a namespace, with source positions attached.

This chapter is the shape of that tree.

Three principles

The whole data model is built around three ideas. They show up everywhere in the rest of this chapter, so it's worth naming them up front.

Uniform shape

Every node is an element, and every element has the same four parts. A traversal that handles one element handles them all — no special cases hiding in the corners.

Semantic typing

Every element answers what it represents, not just what kind of value it carries. Two strings that look identical in JSON can mean very different things — a server URL and a description are both quoted text, but they're not the same element. Tools can ask "give me every OperationElement" instead of "find every node whose parent key matches an HTTP method" — the parser already did that classification.

Clean separation

The value of the document and ApiDOM's bookkeeping never mix. content holds what came from the source; meta and attributes hold everything ApiDOM computed about it. A round-trip serializer can write the document back without leaking any of ApiDOM's own state.

The parse result

parse() doesn't hand you the root document directly. It hands you a parse result: a small wrapper that carries the parsed data alongside everything ApiDOM noticed on the way in.

import { parse } from '@speclynx/apidom-reference';

const result = await parse('/path/to/openapi.json');

result.api;          // the root data model — your OpenAPI document
result.annotations;  // diagnostics collected while parsing

When you parse a clean document, result.api is what you came for and the annotations list is empty. When you parse a malformed one in non-strict mode, the broken parts surface as annotations rather than crashing the parse. Either way, the shape is the same — one place to look for the data, one place to look for what went wrong.

What you have at this point is strictly a tree: every element has exactly one parent, and there are no cycles. $ref pointers stay as reference elements rather than getting inlined. Resolving them in a later chapter can relax both constraints — shared references break the single-parent rule and the structure becomes a directed acyclic graph, while circular references break the no-cycle rule and it becomes a directed cyclic graph. parse() itself never does either.

Everything is an element

Inside result.api you won't find plain JavaScript objects. You'll find elements. Every node in the tree is one — the root document, every operation, every schema, every string, every number.

An element is a small object with four parts:

element.element

the kind — "string", "operation"

element.content

the value — varies by kind

element.meta

metadata — classes, identifiers, titles

element.attributes

semantic attributes specific to the element kind

element.element is the part that makes the model semantic. Two strings that look identical in JSON can mean different things — a server URL is not a description, even if both are quoted text. ApiDOM keeps that distinction by tagging every element with what it is.

Three kinds of element

Every element in the tree is one of three kinds, and each kind builds on the last. The base sets the shape, primitives specialise for JSON's data types, and semantic elements layer in spec-specific meaning on top.

Base

Element

Every node in the tree. Same four parts on every one: element, content, meta, attributes.

Extends Element

Primitive elements

One per JSON data type — null, boolean, number, string, array, object, member. The leaves of every tree.

Extends a primitive

Semantic elements

One per spec concept — operations, schemas, servers, paths, info objects. Grouped into namespaces by specification.

Everything that follows — reading values, walking the tree, validating, round-tripping — works against this hierarchy. The next two sections fill in the primitive and semantic layers.

Primitive elements

At the leaves of the tree sit the primitives. They map one-to-one to the JSON data model:

NullElement

null

BooleanElement

true / false

NumberElement

a number

StringElement

a string

ArrayElement

ordered list of elements

ObjectElement

set of members

MemberElement

key/value pair (both elements)

These are the bricks. Everything else — an entire OpenAPI document — is built from them.

Semantic elements

The semantic elements are where ApiDOM earns its name. They extend the primitives, but each one carries meaning specific to a specification. An OperationElement is still an object underneath — but it answers to element === 'operation', exposes the fields you'd expect from an operation, and can be located by traversal without inspecting keys.

import { parse } from '@speclynx/apidom-reference';

const result = await parse('/path/to/openapi.json');

result.api.element;       // "openApi3_1"
result.api.info.element;  // "info"
result.api.paths.element; // "paths"

This is what makes the model semantic rather than syntactic. Your code can ask "give me every operation" without walking strings and matching keys, because every operation in the tree has already been tagged as one.

Namespaces

Each supported specification lives in its own namespace. A namespace is a bag of semantic elements specific to that spec — OpenAPI 3.1, OpenAPI 3.0, OpenAPI 2.0, Overlay 1.x, AsyncAPI 2.x, Arazzo 1.x, JSON Schema.

When ApiDOM parses your document, it picks the matching namespace and produces semantic elements from it. The primitives are shared across all namespaces; the semantic elements are not. An InfoElement from the OpenAPI 3.1 namespace is not the same type as the one from AsyncAPI 2.6, even though both extend the same primitive object element.

This is how a single library covers every shape of API document without conflating them. Tooling that wants to handle all of them can work against the shared primitives; tooling that targets a single spec can lean on the semantic types of that namespace.

A document in tree form

Put it all together. A short OpenAPI snippet on the left, the tree ApiDOM produces from it on the right — semantic elements at the branches, primitives at the leaves, every node sharing the same four-part shape.

Source

openapi: "3.1.2"
info:
  title: Pet Store
  version: "1.0.0"
paths:
  /pets:
    get:
      summary: List pets

Data model

OpenApi3_1Element
├─ openapi:  StringElement   "3.1.2"
├─ info:     InfoElement
│  ├─ title:    StringElement "Pet Store"
│  └─ version:  StringElement "1.0.0"
└─ paths:    PathsElement
   └─ /pets:  PathItemElement
      └─ get:  OperationElement
         └─ summary: StringElement "List pets"

Reading the model

Even though everything is an element, you don't need to remember that to read a document. Semantic elements expose the fields you'd reach for naturally:

import { parse } from '@speclynx/apidom-reference';
import { toValue } from '@speclynx/apidom-core';

const result = await parse('/path/to/openapi.yaml');

result.api.info.title;            // StringElement
toValue(result.api.info.title);   // "Pet Store" — raw JavaScript string
toValue(result.api.info.version); // "1.0.0"

result.api.paths.forEach((pathItem) => {
  pathItem.element; // "pathItem"
});

Field accessors return child elements, not raw values — a title lookup yields a StringElement, not a primitive string.

Opting out of the data model is a single function call. toValue() from @speclynx/apidom-core returns the plain JavaScript representation of any element — a string for primitives, an object or array for compound elements. It works at any depth, including the root: toValue(result.api) returns the entire document as a plain object.

Where the extras live

meta and attributes are where ApiDOM stores everything that isn't the value itself. Classifications, identifiers, computed properties, references resolved — they all live here. Source map positions are exposed directly on each element for convenience, but the data backing them comes from this same metadata layer.

The separation is deliberate. content stays a faithful representation of what the document said. Everything ApiDOM added during parsing lives alongside it, never inside it — so a round-trip serialization can write the document back without leaking ApiDOM's own bookkeeping into the output.

When to drop down

There's no separate API layer to opt into. Three ways of working with the tree share the same nodes — you move between them as you need to.

Most navigation works through the semantic accessors. They return child elements, not raw values:

result.api.info.title;                          // StringElement
result.api.paths.get('/pets').get('get').summary; // StringElement

Call toValue() from @speclynx/apidom-core when you just want the plain JavaScript value:

import { toValue } from '@speclynx/apidom-core';

toValue(result.api.info.title);   // "Pet Store"
toValue(result.api.info.version); // "1.0.0"
toValue(result.api);              // the whole document, as a plain object

Drop to the element level when you need information the value alone can't carry:

  • Source positions — where in the original document a value came from
  • Classifications and identifiers — metadata the parser attached during recognition
  • The element kind — whether this string is a server URL or a description
  • Custom traversal — finding nodes by predicate or visiting every element of a kind

Same tree either way. The chapters that follow build on it.