When expandContracts Doesn’t Expand: What I Found and How I Fixed It

In our previous post, we introduced a contract-driven pattern for managing allowedTypes in Optimizely CMS — reference a contract instead of listing individual components, and let the SDK resolve the rest. Clean, scalable, and very much in the spirit of the Open-Closed Principle.

It worked great in our initial tests. Then we started testing deeper.

The SDK’s expandContracts: true handles the simplest case well — a single content property pointing to a contract. But with arrays, inline components, and nested references, expansion silently stops. The CMS allows the right components. The GraphQL queries don’t fetch their data.

We tested four property shapes, mapped exactly where expansion breaks, and built a generic runtime fix.

Quick Recap: expandContracts

When using contracts in allowedTypes, the SDK needs to know how to translate a contract reference into concrete types for GraphQL query generation. This is what expandContracts: true does. You set it globally:

import { config } from '@optimizely/cms-sdk';

config({
  apiKey: process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!,
  expandContracts: true,
});

With this enabled, given a contract POC_CommonCardContract extended by CardComponentA and CardComponentB, the SDK should generate inline fragments for both concrete types:

myField {
  __typename
  ...POC_CardComponentA
  ...POC_CardComponentB
}

Instead of just the contract interface, which returns no component-specific data:

myField {
  __typename
  ...IPOC_CommonCardContract
}

The question is — does this happen consistently across all property shapes?

The Four Shapes

We created a test container (POC_CardContainer) with four property shapes, each referencing the contract differently. An intermediate wrapper component (POC_CardWrapper) was used to test nested scenarios — it has both a single content and an array[content] property pointing to the contract.

#ShapeExpands?
1content → contractYes
2array[content] → contractNo
3component → (content/array → contract)No
4content → concrete → (array[content] → contract)Partial

Shape 1: content → contract — Works

A single content reference with the contract in allowedTypes.

shape1_contentToContract: {
  type: 'content',
  displayName: 'Shape 1: content → contract',
  allowedTypes: [POC_CommonCardContract]
}

The SDK’s handleContentProperty correctly reads expandContracts: true, resolves the contract to its extending types, and generates proper inline fragments. This is the only shape that works out of the box.

Shape 2: array[content] → contract — Broken

An array of content items with the contract in allowedTypes. This is the most common real-world shape — a content area accepting multiple cards.

shape2_arrayContentToContract: {
  type: 'array',
  displayName: 'Shape 2: array[content] → contract',
  items: {
    type: 'content',
    allowedTypes: [POC_CommonCardContract]
  }
}

The SDK’s handleArrayProperty processes the array but does not pass expandContracts to the inner content handler. The generated query includes only the contract interface fragment. Result: the response contains _IContent metadata (key, locale, types) but zero component-specific properties — no cardTitle, no cardDescription, nothing usable.

CMS-side behavior is fine — editors can drop Card A and Card B. The frontend just can’t fetch their data.

Shape 3: component → (content/array → contract) — Broken

An inline component property pointing to POC_CardWrapper, which itself has contract-based properties.

shape3_componentWithContractProps: {
  type: 'component',
  displayName: 'Shape 3: component → contract props',
  contentType: POC_CardWrapperCT
}

Where POC_CardWrapperCT has:

properties: {
  innerCard: {
    type: 'content',
    allowedTypes: [POC_CommonCardContract]   // Shape 1 equivalent
  },
  innerCards: {
    type: 'array',
    items: {
      type: 'content',
      allowedTypes: [POC_CommonCardContract]  // Shape 2 equivalent
    }
  }
}

When a content type is referenced via type: 'component', the SDK inlines its properties directly into the parent’s GraphQL query. During that inlining, expandContracts is not propagated. This means even innerCard — which is the exact same shape as Shape 1 and works at the top level — loses expansion when nested inside a component. Both innerCard and innerCards fail.

Shape 4: content → concrete → (array[content] → contract) — Partial

A content reference pointing to POC_CardWrapper as a concrete type (not a contract).

shape4_contentToConcreteWithArrayContract: {
  type: 'content',
  displayName: 'Shape 4: content → concrete → (array[content] → contract)',
  allowedTypes: [POC_CardWrapperCT]
}

Since POC_CardWrapperCT is referenced as type: 'content' (not type: 'component'), the SDK fetches it as a separate content item with its own query. From that query’s perspective:

  • innerCard is a top-level content → contract — Shape 1, works natively
  • innerCards is array[content] → contract — Shape 2, still broken

Same wrapper, different result — because the traversal path determines whether expandContracts is carried through.

The Root Cause

Two gaps in @optimizely/cms-sdk@2.2.0:

  1. handleArrayProperty does not pass expandContracts when processing array items
  2. Component traversal does not propagate expandContracts when inlining properties

CMS-side restrictions work fine. The issue is strictly in GraphQL query generation.

The Fix

Since we can’t modify the SDK, we patch the registered content type definitions in-memory — right after initContentTypeRegistry() and before any queries are generated.

The approach: scan all registered types, find allowedTypes arrays that contain contracts, and append the concrete extending types alongside them. This gives the SDK everything it needs to generate the correct fragments.

import { initContentTypeRegistry, isContract } from '@optimizely/cms-sdk';

function buildContractExtendersMap(registry: any[]): Map<string, any[]> {
  // Map: contract key → content types that extend it
  // e.g. "POC_CommonCardContract" → [CardComponentACT, CardComponentBCT]
  const extenders = new Map<string, any[]>();
  for (const ct of registry) {
    // Normalize `extends` — can be a single contract or an array
    for (const contract of [ct.extends ?? []].flat()) {
      if (!contract?.key) continue;
      // Group this content type under its contract key
      const list = extenders.get(contract.key);
      list ? list.push(ct) : extenders.set(contract.key, [ct]);
    }
  }
  return extenders;
}

function findInlineComponentKeys(registry: any[]): Set<string> {
  // Collect keys of CTs referenced via type: 'component'
  // Their properties are inlined into the parent query,
  // and expandContracts is NOT propagated during that inlining
  const keys = new Set<string>();
  for (const ct of registry) {
    if (!ct.properties) continue;
    for (const prop of Object.values(ct.properties) as any[]) {
      if (prop.type === 'component' && prop.contentType?.key) {
        keys.add(prop.contentType.key);
      }
    }
  }
  return keys;
}

function resolveAllowedTypes(
  allowedTypes: any[],
  extenders: Map<string, any[]>,
): any[] | null {
  const concrete: any[] = [];
  for (const entry of allowedTypes) {
    if (isContract(entry)) {
      // Replace this contract with all content types that extend it
      concrete.push(...(extenders.get(entry.key) ?? []));
    }
  }
  // Return original + concrete types, or null if no expansion needed
  return concrete.length > 0 ? [...allowedTypes, ...concrete] : null;
}

function expandContractRefs(registry: any[]) {
  // Step 1: Build contract → extenders lookup
  const extenders = buildContractExtendersMap(registry);
  if (extenders.size === 0) return;
  // Step 2: Identify CTs used as inline components
  const inlineComponentKeys = findInlineComponentKeys(registry);
  // Step 3: Expand contracts where the SDK fails
  for (const ct of registry) {
    if (!ct.properties) continue;
    const isInlineComponent = inlineComponentKeys.has(ct.key);
    for (const prop of Object.values(ct.properties) as any[]) {
      // Array items — SDK never passes expandContracts here (Shape 2)
      if (prop.type === 'array' && prop.items?.allowedTypes) {
        const resolved = resolveAllowedTypes(prop.items.allowedTypes, extenders);
        if (resolved) prop.items.allowedTypes = resolved;
      }
      // Content properties on inline components only (Shape 3)
      // Top-level content → contract (Shape 1) works natively — skip those
      if (prop.type === 'content' && prop.allowedTypes && isInlineComponent) {
        const resolved = resolveAllowedTypes(prop.allowedTypes, extenders);
        if (resolved) prop.allowedTypes = resolved;
      }
    }
  }
}

Usage — two lines, right after registry initialization:

const contentTypeRegistry = [
  MyContract,
  ComponentACT,
  ComponentBCT,
  ContainerCT,
  // ... all your content types and contracts
];

initContentTypeRegistry(contentTypeRegistry);
expandContractRefs(contentTypeRegistry);

No hardcoded types. Add a new component that extends any contract, and it’s picked up automatically.

Note: This is a runtime workaround for a known issue in @optimizely/cms-sdk@2.2.0. This has been accepted as a bug by the Optimizely team and is being tracked under CMS-54935. Once the SDK natively propagates expandContracts through array items and component traversal, this workaround can be removed.

Summary

Final Thoughts

expandContracts is the right idea — it just doesn’t go far enough. Out of six property shapes, only two work natively. Arrays and inline components are left behind.

The workaround presented here is generic, non-invasive, and runs in a single pass at startup. It doesn’t modify node_modules, doesn’t require patch-package, and doesn’t break when new components are added. It simply fills the gaps until the SDK catches up.

A support ticket has been raised with the Optimizely team for both issues:

  • handleArrayProperty not passing expandContracts to the content handler
  • Component traversal not propagating expandContracts when inlining properties

Until then — drop expandContractRefs into your registry and move on.

Happy Optimizing!!!

Leave a comment