ADR
Resolve `AnyProp` `oneOf` schema violation between `TextProp` and `IconProp`
Context
AnyProp in schema/component.schema.json uses oneOf to discriminate between five prop types:
AnyProp: oneOf: - $ref: BooleanProp # type: "boolean" - $ref: TextProp # type: "string" - $ref: IconProp # type: "string" <-- identical shape to TextProp - $ref: EnumProp # type: "string" + required enum[] - $ref: SlotProp # type: "slot"TextProp and IconProp have identical schemas: same const value for type ("string"), same optional fields (default, nullable, examples), same required array (["type"]), and same additionalProperties: false. The TypeScript interfaces in types/Props.ts are likewise identical except for their names and JSDoc comments.
Under JSON Schema oneOf semantics, a value must match exactly one branch. Any valid TextProp instance also validates against IconProp (and vice versa), producing two matches. This causes oneOf to reject the value — making every text or icon prop invalid according to the schema.
This is a violation of Constitution IV (Schema Validity Must Be Mechanically Verifiable).
Decision Drivers
- Schema validity (Constitution IV): The JSON schema must be valid and mechanically verifiable. A
oneOfwith two indistinguishable branches is structurally broken. - Type-schema symmetry (Constitution I): Changes to the schema must be reflected in the TypeScript types and vice versa.
- Stable public API (Constitution III):
TextPropandIconPropare individually exported fromtypes/index.ts. Removing either is a breaking change to the type surface. - No logic in this package (Constitution II): The fix must remain purely declarative — types and schema only.
- Minimal change: Prefer the smallest structural change that restores schema validity.
- Future divergence potential: Text and icon props may evolve differently. Text examples are plain strings; icon examples may reference named assets, icon sets, or component references. A solution that forecloses future structural divergence incurs design debt.
Options Considered
Option A: Merge TextProp and IconProp into a single StringProp (Selected)
Collapse both types into one StringProp since they are structurally identical. The oneOf would have four branches instead of five, each structurally distinct.
Type changes:
# Before (types/Props.ts)AnyProp: BooleanProp | TextProp | IconProp | EnumProp | SlotProp
TextProp: type: "string" default?: string nullable?: boolean examples?: string[]
IconProp: type: "string" default?: string nullable?: boolean examples?: string[]
# AfterAnyProp: BooleanProp | StringProp | EnumProp | SlotProp
StringProp: type: "string" default?: string nullable?: boolean examples?: string[]Schema changes: Replace TextProp and IconProp definitions with a single StringProp; update AnyProp.oneOf to reference it.
Pros:
- Eliminates the
oneOfviolation cleanly — four branches, all structurally unique - Reflects truth: the output shape genuinely has no text-vs-icon distinction today
- Simplest structural change to the schema
- Does not foreclose future re-separation: If text and icon props later need distinct shapes,
StringPropcan be split back into two types at that point. A future discriminator (see Option B variants below) can be introduced alongside the structural divergence in a single MAJOR bump, rather than paying for the discriminator now when the shapes are identical.
Cons / Trade-offs:
- Breaking change: Removes two exported types (
TextProp,IconProp) from the public API and replaces them withStringProp— requires a MAJOR bump - Erases the semantic intent that text props and icon props are conceptually different prop categories
- All downstream consumers must update import references
Option B: Add a discriminator property (Rejected — deferred as future work)
Add a discriminating field to each prop type, making them structurally distinguishable. Three sub-variants were considered for where to house the discriminator:
B1 — Top-level kind field
A required kind field with a const value on each prop type. Standard JSON Schema discrimination pattern.
TextProp: kind: "text" # const — new required field type: "string" default?: string nullable?: boolean examples?: string[]
IconProp: kind: "icon" # const — new required field type: "string" default?: string nullable?: boolean examples?: string[]Simple and idiomatic, but introduces a field whose sole purpose is schema discrimination — it carries no domain meaning beyond “this is a text prop” vs “this is an icon prop.”
B2 — x-platform metadata
Use the x-platform pattern already established by BooleanProp to carry Figma-origin type information:
TextProp: type: "string" x-platform: FIGMA: type: "TEXT" # maps back to Figma's ComponentPropertyType
IconProp: type: "string" x-platform: FIGMA: type: "INSTANCE_SWAP"Leverages an existing schema pattern and carries real provenance data. The discriminator would reflect the actual Figma property type that produced this prop. However, x-platform is currently optional on BooleanProp — making it required here to achieve oneOf discrimination would be inconsistent, and leaving it optional doesn’t fix the oneOf problem.
B3 — $extensions metadata
Use the DTCG $extensions pattern already established by TokenReference (which uses $extensions["com.figma"] for platform provenance):
TextProp: type: "string" $extensions: "com.figma": type: "TEXT"
IconProp: type: "string" $extensions: "com.figma": type: "INSTANCE_SWAP"Aligns with the DTCG convention used elsewhere in the schema. Carries real Figma provenance. Same optional-vs-required tension as B2.
Pros (all B variants):
- Preserves the semantic distinction between text and icon props
- Both existing type names survive — no breaking rename
- Supports future divergence: once a discriminator is in place,
TextPropandIconPropcan evolve independently without another breaking change to the discrimination mechanism - B2/B3 carry real platform provenance data, not just a synthetic tag
Cons / Trade-offs (all B variants):
- Adds a new field to the output — all producers must emit it, all consumers must handle it
- Breaking change if the discriminator field is required (existing valid output lacks it); optional doesn’t fix
oneOf - Premature when shapes are identical: Adding a discriminator to distinguish two types that have no structural difference solves a schema-mechanics problem but doesn’t reflect a genuine domain distinction in the output today. The discriminator becomes meaningful only when the shapes actually diverge.
- B2/B3 carry additional complexity (nested objects) compared to B1’s flat
kindfield
Compatibility with Option A: All B variants are additive and can be introduced later on top of a merged StringProp (or a re-split TextProp/IconProp) when the shapes actually diverge. Selecting A now does not preclude B later.
Option C: Replace oneOf with anyOf on AnyProp (Rejected)
Switch AnyProp from oneOf (exactly one match) to anyOf (at least one match). Multiple matching branches would no longer cause a validation failure.
Schema changes:
# BeforeAnyProp: oneOf: [BooleanProp, TextProp, IconProp, EnumProp, SlotProp]
# AfterAnyProp: anyOf: [BooleanProp, TextProp, IconProp, EnumProp, SlotProp]Type changes: None — TypeScript union semantics already behave like anyOf.
Pros:
- Zero-cost fix: one keyword change, no type changes, no downstream impact
- Preserves both type names and all existing shapes
- TypeScript union (
|) already hasanyOfsemantics, so this aligns the schema with the type system - Keeps the door open: If the shapes diverge later (e.g., icon examples become asset references), the types are already separate and the schema can be tightened back to
oneOfonce the shapes are naturally distinct — no breaking change needed at that point
Cons / Trade-offs:
- Weakens validation:
anyOfpermits ambiguous matches by design, which may mask future schema errors if other branches become overlapping - Does not solve the underlying issue (identical shapes) — defers it
- Validators can no longer assert that exactly one prop type matched, reducing diagnostic precision
- Bets on future divergence that may never happen: If the shapes remain identical indefinitely, the two definitions stay redundant and the schema carries dead weight with no validation benefit
Option D: Add an icon-specific structural property to IconProp (Rejected)
Introduce a property unique to IconProp (e.g., iconSet, iconLibrary, or iconName) that makes it structurally distinct from TextProp.
Type changes:
# AfterIconProp: type: "string" default?: string nullable?: boolean examples?: string[] iconSet?: string # new optional field — icon library or collection nameSchema changes: Add the new property to IconProp definition only.
Pros:
- Preserves both type names and the semantic distinction
- Makes the shapes genuinely different, so
oneOfworks correctly - Additive change — MINOR bump if the field is optional
- Naturally accommodates divergence: An icon-specific property like
iconSetwould carry real domain meaning. If iconexampleslater evolve to reference named assets,iconSetprovides the namespace context those references need.
Cons / Trade-offs:
- Invents a property that no producer currently emits and no consumer currently reads — the field would be empty or absent in all current output
- Must justify that the new property represents a genuine shared concept (Constitution III) rather than a schema workaround
- Does not actually fix
oneOfif optional: An always-absent optional field technically still allows both branches to match simultaneously. Onlyrequired+constfields reliably discriminate underoneOf. MakingiconSetrequired would be a breaking change (MAJOR), defeating the MINOR advantage. - If the divergence direction turns out to be something other than icon sets (e.g., icon examples become structured objects), this field may end up as dead weight alongside the actual differentiating property
Decision
Selected: Option A — Merge TextProp and IconProp into a single StringProp.
Type changes (types/)
| File | Change | Bump |
|---|---|---|
Props.ts | Remove TextProp and IconProp interfaces; add StringProp interface with identical shape | MAJOR |
Props.ts | Update AnyProp union: BooleanProp | StringProp | EnumProp | SlotProp | MAJOR |
index.ts | Remove TextProp, IconProp exports; add StringProp export | MAJOR |
Example — new shape (types/Props.ts):
# BeforeAnyProp: BooleanProp | TextProp | IconProp | EnumProp | SlotProp
TextProp: type: "string" default?: string nullable?: boolean examples?: string[]
IconProp: type: "string" default?: string nullable?: boolean examples?: string[]
# AfterAnyProp: BooleanProp | StringProp | EnumProp | SlotProp
StringProp: type: "string" default?: string nullable?: boolean examples?: string[]Schema changes (schema/)
| File | Change | Bump |
|---|---|---|
component.schema.json | Remove TextProp and IconProp definitions; add StringProp definition with identical shape | MAJOR |
component.schema.json | Update AnyProp.oneOf: replace TextProp and IconProp refs with single StringProp ref | MAJOR |
Example — new shape (schema/component.schema.json):
# Before — AnyProp.oneOf- $ref: "#/definitions/BooleanProp"- $ref: "#/definitions/TextProp"- $ref: "#/definitions/IconProp"- $ref: "#/definitions/EnumProp"- $ref: "#/definitions/SlotProp"
# After — AnyProp.oneOf- $ref: "#/definitions/BooleanProp"- $ref: "#/definitions/StringProp"- $ref: "#/definitions/EnumProp"- $ref: "#/definitions/SlotProp"Notes
Deferred decision — discriminator property for future re-separation: If StringProp is later split back into distinct text and icon types (e.g., because icon examples items evolve to structured asset references while text examples remain plain strings), the re-separated types will need a discriminator to satisfy oneOf. Three candidate patterns were evaluated in Option B:
kind: Flat top-level const field (kind: "text"/kind: "icon"). Simplest, but purely synthetic.x-platform: Nested platform-origin metadata following theBooleanProppattern (x-platform.FIGMA.type: "TEXT"/"INSTANCE_SWAP"). Carries real provenance.$extensions: DTCG-aligned metadata following theTokenReferencepattern ($extensions["com.figma"].type: "TEXT"/"INSTANCE_SWAP"). Consistent with existing DTCG conventions in this schema.
The choice between these patterns is deferred until the shapes actually diverge and a discriminator is needed. At that point, the discriminator and structural divergence can be introduced together in a single MAJOR bump.
Type <> Schema Impact
- Symmetric: Yes — both
types/Props.tsandschema/component.schema.jsonreplace the same two definitions with one - Parity check:
StringPropinterface ↔#/definitions/StringPropschema definition;AnyPropunion ↔AnyProp.oneOf
Downstream Impact
| Consumer | Impact | Action required |
|---|---|---|
anova-kit | Recompile — TextProp and IconProp imports no longer exist | Replace TextProp / IconProp imports with StringProp |
Semver Decision
Version bump: Part of 0.13.0 (pre-1.0 — breaking changes are permitted within minor releases)
Justification: Removes two exported types (TextProp, IconProp) from the public API and replaces them with StringProp. This is a breaking change per Constitution III, but acceptable within a pre-1.0 minor release where the public API is not yet stabilized.
Consequences
AnyPropschema validation will no longer reject valid prop instances — Constitution IV compliance is restored- Runtime validators consuming
component.schema.jsonwill produce correct results for string-typed props - The semantic distinction between “text prop” and “icon prop” is no longer encoded in the type system or schema — consumers that need this distinction must derive it from context (e.g., element type, prop name)
TextPropandIconPropare removed from the public API — all downstream consumers must update imports toStringProp- Future re-separation into distinct text/icon types remains possible; the discriminator pattern (Option B) is documented and deferred until structural divergence justifies it