0007: Canonical rules for object inlining, naming, and converter options
Date: 2025-12-14
Status
Proposed
Context
During cross-language parity work between the Node and Rust converters we discovered a set of small but recurring semantic mismatches that are best resolved by choosing and documenting canonical rules. The disagreements center on:
- When to emit an anonymous object as a named GraphQL type vs treat it as an opaque
JSONvalue - How to format GraphQL descriptions (inline vs block) deterministically
- How to produce stable, sanitized GraphQL identifiers for types and fields
- How
$refnaming should map to GraphQL type names in deterministic ways
Without a documented, agreed baseline, parity tests will keep surfacing behavioral differences that are design choices rather than bugs. This ADR captures the canonical defaults and the configuration surface by which they can be overridden.
Decision
We adopt the following canonical rules (defaults) and surface them as ConverterOptions (converter-level options) and per-schema overrides via extension fields (prefixed with x-graphql- in JSON Schema).
-
Description formatting
- Rule: Emit a block string (GraphQL triple-quoted) when the description contains a newline or its length exceeds
descriptionBlockThresholdcharacters; otherwise emit an inline string. - Default:
descriptionBlockThreshold = 80. - Inline descriptions must end with a single newline when rendered (to ensure separation from type definition lines).
- Rule: Emit a block string (GraphQL triple-quoted) when the description contains a newline or its length exceeds
-
Empty object types
- Rule: Do not emit an explicit GraphQL object definition for an object that has no fields and does not declare useful constraints (e.g.
enum,additionalPropertieswith known structure). Omit emitting a type to avoid creating invalid or useless SDL. - Default:
emitEmptyTypes = false.
- Rule: Do not emit an explicit GraphQL object definition for an object that has no fields and does not declare useful constraints (e.g.
-
Anonymous inline objects
- Rule: For anonymous objects that are small and not referenced elsewhere, prefer representing them as the
JSONscalar instead of emitting a new named GraphQL object type. This significantly reduces anonymous type churn and yields more pragmatic SDL. - Heuristics for inlining as
JSON(defaults):inlineObjectThreshold(max properties) = 3- object has no
requiredfields that must be surfaced as GraphQL fields - object does not have schema features that require structural typing in GraphQL (e.g.
oneOf,anyOfwhere alternatives map to GraphQL unions)
- If these heuristics are not met, or the object is referenced multiple times, emit a named type.
- Rule: For anonymous objects that are small and not referenced elsewhere, prefer representing them as the
-
Naming & sanitization
- Type names: Use PascalCase, strip or replace invalid characters, collapse separators (spaces,
-,_,.) into word boundaries, and prefix withTif the name would start with a digit. Examples:my-type.json->MyTypeJson123-foo->T123Foo
- Field names: Use camelCase, strip invalid characters, and replace separators with word boundaries. If a field name would start with a digit, prefix with
f. - Default sanitizers are conservative and deterministic to minimize surprises.
- Type names: Use PascalCase, strip or replace invalid characters, collapse separators (spaces,
-
$refnaming strategy- Rule: By default, derive type names from the last non-empty path segment of the
$ref(filename or fragment) then sanitize according to the Type naming rules above (this is calledbasenamestrategy). - Option: Expose
refNamingwith allowed valuesbasename(default),file_and_path(include parent directories), orhash(stable hash when name collisions require it).
- Rule: By default, derive type names from the last non-empty path segment of the
-
Unique inline type names
- Rule: When an inline object must be named (e.g., to render fields this is emitted as an actual GraphQL type) and it would collide with another generated name or participate in cycles, generate an appended numeric suffix (e.g.
NestedObject,NestedObject#1,NestedObject#2) in a deterministic order.
- Rule: When an inline object must be named (e.g., to render fields this is emitted as an actual GraphQL type) and it would collide with another generated name or participate in cycles, generate an appended numeric suffix (e.g.
-
Options & schema extensions
- Expose these options as a typed
ConverterOptionson the converter API and allow schema-scoped overrides usingx-graphql-*extension fields. The converters must accept these options and use them consistently.
- Expose these options as a typed
Representative ConverterOptions (informal JSON schema)
{
"descriptionBlockThreshold": { "type": "number", "default": 80 },
"emitEmptyTypes": { "type": "boolean", "default": false },
"inlineObjectThreshold": { "type": "number", "default": 3 },
"refNaming": { "type": "string", "enum": ["basename","file_and_path","hash"], "default": "basename" },
"typeNameSanitizer": { "type": "string", "enum": ["pascal"], "default": "pascal" },
"fieldNameSanitizer": { "type": "string", "enum": ["camel"], "default": "camel" }
}Suggested per-schema overrides:
x-graphql-description-block-threshold: numberx-graphql-emit-empty-types: booleanx-graphql-inline-object-threshold: numberx-graphql-ref-naming: one ofbasename|file_and_path|hash
Examples
- Long description -> block string
Input schema excerpt:
{
"description": "This is a very long description... ( >80 chars )\nIt contains additional lines."
}Output SDL (canonical):
""" This is a very long description… It contains additional lines. """
type Foo { ... }- Small anonymous object ->
JSON
Input:
{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"number"}}}Output (canonical):
type Foo {
data: JSON
}- Larger or referenced object -> named type
Input (referenced or > threshold):
Output:
type Parent {
nested: NestedObject
}
type NestedObject {
a: String
b: Int
}Consequences
- Tests & fixtures: Add parity fixtures that exercise every option and edge case (inline-vs-named, description length, unusual
$refforms, empty objects, sanitizer edgecases). These fixtures will lock the behavior and prevent regressions. - Backwards compatibility: Some existing fixtures may change their canonicalSDL; the parity suite will be the authoritative signal. Changes should be applied in small, reviewed commits.
- API surface: Both Node and Rust converters must accept
ConverterOptionsand the per-schema overrides. The default behavior should mirror the rules above so that current behavior is preserved unless explicitly overridden.
Implementation Plan
- Create this ADR (done).
- Add 2–4 parity fixtures to
converters/test-data/to cover: long descriptions, empty objects, small anonymous object inlining,$refnaming collisions. - Add
ConverterOptionstype to both Node and Rust implementations and update parsing ofx-graphql-*schema extensions. - Update converters to follow these defaults and add unit tests for sanitizer, description formatting, and naming.
- Re-run parity tests and iterate on any remaining diffs; if diffs represent acceptable deviations, update fixtures and tests to lock the intended behavior.
Alternatives Considered
- Do nothing and continue to allow converters to differ. Rejected — this leaves parity tests noisy and slows progress.
- Hard-code behaviours into tests (i.e., make parity more permissive). Rejected — it hides real design decisions that should be explicit and documented.
Next Steps
- If accepted, I will add the proposed
ConverterOptionsshape toconverters/nodeandconverters/rustcodepaths, add the new fixtures underconverters/test-data/, and updateconverters/node/src/parity.test.tsto include tests that assert the default options and per-schema overrides produce the expected SDL/AST.