Refs and resolution
How OpenAPI $refs are parsed, tracked, and resolved through the SKMTC pipeline: OasRef as a sibling-class to schema variants, lazy resolution with cycle protection, forward-reference handling via…
OAS documents express reuse through $ref. SKMTC's job is to make
refs first-class without forcing them to resolve at parse time —
which would require either two passes or strict topological ordering.
The mechanism: OasRef is a class with a live reference to the
in-progress parsed document. Resolution is lazy and happens at
access time, by which point the document is fully populated.
The one-line definition
When the parser encounters $ref: '#/components/schemas/User', it
constructs an OasRef carrying just the $ref string and a reference
to context.parsedDocument. The document is mutable and being
built during the walk. By the time anyone calls .resolve() on the
ref, the document has been populated, so the lookup succeeds.
OasRef as a sibling class
A common LLM intuition: "Refs are wrappers around schemas; they should be a subclass of schema."
SKMTC's design is different. OasSchema is a discriminated union:
type OasSchema =
| OasObject
| OasArray
| OasString
| OasInteger
| OasNumber
| OasBoolean
| OasUnion
| OasUnknownOasRef is not in this union. It's a separate class. Generators
that handle "either a schema or a ref to one" use the union
OasSchema | OasRef<'schema'>.
This split has a meaningful consequence: the type system requires
generator authors to call .isRef() before accessing schema-specific
properties:
function generate(schemaOrRef: OasSchema | OasRef<'schema'>) {
if (schemaOrRef.isRef()) {
// schemaOrRef is OasRef<'schema'>; .type, .properties not accessible
const resolved = schemaOrRef.resolve()
// resolved is OasSchema
} else {
// schemaOrRef is OasSchema; can access .type directly
if (schemaOrRef.type === 'object') {
// ...
}
}
}Every variant — OasObject, OasArray, OasString, … and OasRef
— independently implements .isRef():
- On
OasRef: returnstrue - On every schema variant: returns
false
So .isRef() is the type-discriminating guard. The type system
threads through the narrowing automatically.
The forward-ref problem
OAS documents can have refs that point at schemas defined later in the same file:
components:
schemas:
Post:
type: object
properties:
author: { $ref: '#/components/schemas/User' } # ← User defined later
User:
type: object
properties:
name: { type: string }A naive parser would need either:
-
Two passes: parse all schemas first, then resolve refs. Adds complexity.
-
Strict ordering: require schemas to be defined before references. Constrains the input.
-
Lazy resolution: store the ref, resolve when needed. Requires the resolution target to be accessible at resolution time.
SKMTC takes path 3. The trick: OasRef is constructed during the
walk with a reference to context.parsedDocument, which is the
in-progress parsed document. The document is mutated as the walk
progresses — when User finishes parsing, it's added to
oasDocument.components.schemas['User']. By the time any consumer
calls someRef.resolve(), the document is fully populated.
// core/parse/v3-{0,1}/ref/toRefV31.ts:33-37
context.registerRef(stackTrail.clone(), $ref)
return context.withStackTrail(stackTrail, () =>
new OasRef({ refType, $ref, nullable }, context)
)The OasRef constructor stores context.parsedDocument, so the
instance holds a live reference to the parsed document.
Mutation of the document by later parser code is visible through
this reference.
This is the empty-instance-issued-up-front pattern: an empty
OasDocument is constructed at the start of parse, refs capture a
reference to that wrapper, the document is mutated in place at end
of parse, and the refs now resolve through the populated fields.
The pattern is applied symmetrically to GqlDocument for GraphQL
inputs. Together they are what makes lazy resolution work without
requiring two parse passes or strict topological ordering of
components.
See error-handling-philosophy.md
for the symmetric GqlDocument application and the related
implementation choices.
resolve() vs resolveOnce()
OasRef exposes two resolution methods:
-
resolveOnce()— does one lookup. If the target is itself a$ref(a ref to a ref), returns the nextOasRef. Useful when you want to see the immediate target. -
resolve()— recursively chases until reaching a non-ref. If there are 5 layers of$ref → $ref → $ref → $ref → schema, returns the schema at depth 5. Throws if the chain exceedsMAX_LOOKUPS(10).
Most consumers want resolve() — they don't care whether the path
is direct or indirect, they want the underlying schema.
// Typical usage in a generator
const schema = bodyRef.resolve() // unwraps any ref chain
if (schema.type === 'object') {
// work with the resolved object schema
}Type integrity check on resolution
resolveOnce() performs a type-integrity check. The check ensures
the resolved value is of the expected refType:
resolveOnce(): OasRef<T> | ResolvedRef<T> {
const refName = toRefName(this.$ref)
const resolved = this.#resolveOasOnce(...) ?? this.#resolveGqlOnce(...)
if (!resolved) {
throw new Error(`Ref "${this.#fields.$ref}" not found`)
}
if (resolved.isRef()) {
if (resolved.refType !== this.refType) {
throw new Error(
`Ref type mismatch for "${this.$ref}". Expected "${this.refType}" but got "${resolved.refType}"`
)
}
} else {
if (resolved.oasType !== this.refType) {
throw new Error(
`Type mismatch for "${this.$ref}". Expected "${this.refType}" but got "${resolved.oasType}"`
)
}
}
return resolved as OasRef<T> | ResolvedRef<T>
}So a OasRef<'schema'> that resolves to a parameter (different
refType) throws at resolution time. This catches authoring mistakes
in the OAS document — e.g., a path that says $ref: '#/components/parameters/userId' in a request-body schema position
(wrong component bucket) would throw on .resolve() with a clear
error.
Cycle protection: MAX_LOOKUPS
What about A → B → A → B → ... cycles?
// core/oas/ref/Ref.ts:16
const MAX_LOOKUPS = 10resolve() tracks recursion depth. If it exceeds 10 hops, it throws
Max lookups reached. This catches cycles before they crash with
stack overflow.
The number 10 is deliberately small. Real OAS schemas almost never need a ref chain longer than 2-3 hops. A 10-hop chain is essentially always a cycle or a pathological schema worth flagging.
Ref tracking during parse
Two maps live on ParseContext:
-
#refConsumers: Map<refKey, StackTrail[]>— every$refencounter callscontext.registerRef(stackTrail.clone(), $ref). This builds the "who pointed at this ref?" inverse index. The.clone()is essential: trails are mutable, and an un-cloned trail would mutate as the walk returned through parent frames. See the-stack-trail.md for the full clone-on-store discussion. -
#refErrors: Map<refKey, unknown[]>— when a parse error happens at a component position,logIssueNoKeyauto-registers the error against the ref. This builds "what went wrong with this ref?"
The two maps together feed cascade pruning. They share a key
namespace (the $ref string) reached from two directions: ref
encounters populate #refConsumers from the literal $ref in the
document; error registration populates #refErrors from the
current trail converted via StackTrail.toStackRef(). See
the-stack-trail.md
for that address-bridging step.
Cascade pruning
At end-of-parse, removeErroredItems walks both maps:
for (const [refKey, errors] of this.#refErrors) {
for (const error of errors) {
const consumers = this.#refConsumers.get(refKey) ?? []
for (const stackTrail of consumers) {
const removed = oasState.oasDocument.removeItem(stackTrail)
if (removed) {
this.issues.push({
type: 'INVALID_DEPENDENCY_REF',
level: 'error',
location: stackTrail.toString(),
...
})
}
}
}
}For each failed schema, every consumer (anything that $ref-ed it)
is pruned from the parsed document. Each pruning logs an
INVALID_DEPENDENCY_REF issue at the consumer's location.
This cascade is one-hop deep. Transitive consumers (consumers-of-consumers) are not pruned at this stage. They may fail at generate time when the now-missing ref is resolved.
See error handling philosophy for more on the cascade model.
Common questions
What's the difference between refType and oasType?
refTypeis what theOasRefexpects to resolve to. It's declared at construction time (new OasRef({ refType: 'schema', ... })) based on the parser's context — e.g., the parser at a request-body schema position createsOasRef<'schema'>.oasTypeis what the resolved value actually is. On a parsed schema variant,oasTypeis'schema'. On a parsed parameter, it's'parameter'. Etc.
The integrity check compares refType (expected) against oasType
(actual). Mismatch means the OAS document has a wrong-bucket ref.
Can I have a ref to a ref?
Yes. $ref: '#/components/schemas/AliasToUser' where AliasToUser
is itself { $ref: '#/components/schemas/User' } resolves correctly
via resolve(). The intermediate OasRef is followed transparently.
MAX_LOOKUPS caps the chain length at 10.
What if I resolve a ref that was pruned?
resolveOnce() throws Ref "..." not found. This typically happens
when a generator runs against the parsed document and a depth-2+
cascade missed pruning the consumer. The generator's try/catch
catches it; the operation is marked 'error' in the manifest.
When should I use resolve() vs resolveOnce()?
Almost always resolve(). The exception is when you specifically
want to detect "this is a ref chain" — e.g., for tooling that
displays the resolution path. Generator code almost never cares about
intermediate hops; it wants the underlying schema.
Is OasRef covariant in its type parameter?
Yes, structurally. An OasRef<'schema'> is a OasRef<'schema'>;
the type parameter is the refType. Different ref types
(OasRef<'schema'> vs OasRef<'parameter'>) are distinct types.
This is what enforces the integrity check at the type level — a
function declaring OasRef<'schema'> can't be passed an
OasRef<'parameter'>.
Can OasRef point to refs in external documents?
Not currently. The $ref is resolved within context.parsedDocument,
which is single-document. External $refs (e.g.,
$ref: 'other.yaml#/User') aren't supported. If your OAS uses
multi-file refs, bundle them into a single document before passing
to SKMTC (tools like @redocly/cli bundle do this).
Does Render phase resolve refs?
No. Render is pure serialization of the file map. By the time Render
runs, generators have already resolved any refs they cared about
during Generate. The parsedDocument is still accessible in theory
but Render doesn't touch it.
Further reading
- The three phases — where refs are constructed and resolved
- Error handling philosophy — the cascade-pruning model
- The StackTrail — the position-stack that addresses ref consumers and bridges to
$refstrings - The type system — how a model generator's
schemaToValueFnhandlesOasRef<'schema'>alongside the schema variants - API reference: oas-ref — full method signatures
- API reference: oas-schema-variants — the schema union
skmtc-debugskill — operational diagnosis of ref failures
Languages
SKMTC's engine is language-blind: @skmtc/core constructs no language object, holds no language reference, and ships no rendering for any target language. Every language-specific concern — what a…
Cross-generator coordination
Why generators can run in any order and still produce consistent output: memoization keyed by deterministic identifiers, with cache hits guaranteed to produce the same Definition that a fresh…