TypeScript type transformation for Sanity CMS: Automatically resolving references and removing system keys in dynamic page builder components

The Challenge

We decided to migrate our CMS from Contentful to Sanity, this to achieve substantial reductions in our monthly operational costs. While Contentful's pricing model had become misaligned with our content requirements, Sanity's structure allows us to maintain predictable, fixed operational expenses regardless of scale. Although the migration required a significant upfront investment in developer resources, this strategic move has positioned us for long-term cost efficiency and better aligns our infrastructure costs with our business needs.

When building dynamic page builders with Sanity CMS, developers face a fundamental type safety problem. Sanity's TypeGen generates types based on raw data storage format, including unresolved references and internal system metadata. However, your frontend components need clean, resolved data structures.

This creates a critical mismatch between generated types and actual runtime data, forcing developers to choose between type safety and maintainability.

Understanding the Problem

The Page Builder Context

Modern content management demands flexibility. In our system, Sanity CMS powers a modular page builder that gives content creators complete creative freedom:

  • Component Library: Pre-built, reusable components (team lists, content sections, CTAs)

  • Drag & Drop Interface: Content creators arrange components in any order

  • Dynamic Configuration: Each component's content and settings are fully customizable

  • Unlimited Combinations: Every page becomes unique and unpredictable

This flexibility creates two levels of technical complexity:

  1. Multi-level Nesting: Components contain other components (e.g., TeamMemberListTeamMemberItemLink)

  2. Runtime Uncertainty: Pages don't know which components will be present until runtime

TypeGen Limitations

Sanity's TypeGen has two critical limitations for page builders:

1. Incomplete Nested Types (Schema based generation)

When dealing with nested components, TypeGen generates internalGroqTypeReferenceTo markers instead of actual resolved types:

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

Your frontend receives resolved data, but TypeScript doesn't understand the relationship between references and actual content.

2. System Field Pollution (Schema based generation)

Generated types include Sanity's internal system fields (_createdAt, _rev, _weak, etc.) that your frontend components don't need and shouldn't handle.

GROQ Query Limitations (Query based generation)

When using conditional queries like _type == "accordion" => @->{ ... }, TypeGen cannot determine which specific component type will be returned at runtime. This creates union type ambiguity where TypeScript loses track of nested data structures.

The Solution: Advanced Type Transformation

Our solution leverages TypeScript's advanced type system to automatically transform raw Sanity types into clean, fully-typed objects that match your actual runtime data.

Key Benefits

  • Type Safety: Compile-time guarantees for clean data structures

  • Zero Runtime Overhead: Pure compile-time type transformation

  • Developer Experience: Work with predictable, clean APIs

  • Automatic Reference Resolution: No more dealing with _ref objects

  • Preserved Relationships: Maintains semantic meaning while improving usability

Implementation Deep Dive

Step 1: Define System Keys to Remove

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This union type acts as a "blacklist" of internal Sanity properties that should be excluded from our clean frontend types.

Step 2: Reference Detection

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This conditional type serves as a type-level predicate, identifying which properties represent Sanity references that need resolution.

Step 3: Reference Type Extraction

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This type performs three critical operations:

  1. Extracts the reference key using infer U

  2. Maps to actual types via ReferenceTypeMap lookup

  3. Returns resolved type instead of reference placeholder

Step 4: Optional Property Preservation

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This utility maintains the optional/required nature of properties during transformation—essential for preserving the original API contract.

Step 5: The Core Transformation Engine

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This recursive conditional type orchestrates the entire transformation:

Array Handling

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

Recursively cleans each array element while preserving array structure.

Reference Resolution

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

Detects Sanity references and replaces them with their resolved types.

Object Transformation

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

For objects, this:

  • Filters system keys using mapped type key remapping

  • Resolves references to their actual types

  • Preserves optionality from the original schema

  • Recurses deeply through all nested properties

Primitive Pass-through

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

Returns primitive types unchanged.

Step 6: Reference Type Mapping

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

This centralized mapping bridges Sanity's internal references with your TypeScript types, ensuring:

  • Type Safety: References resolve to correct types

  • Maintainability: All mappings in one location

  • Extensibility: Easy to add new content types

Real-World Transformation Example

Before: Raw Sanity Types

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

After: Clean Transformation

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop


Transformation Results

The clean type demonstrates several key improvements:

  • System fields removed: No more _id, _createdAt, _updatedAt, _rev

  • References resolved: Full type definitions instead of _ref objects

  • Optionality preserved: Optional fields remain optional (icon?, iconPosition?)

  • Nested structure maintained: Deep resolution through multiple levels

  • Type safety guaranteed: Full IntelliSense and compile-time checking

Usage in Components

With clean types, your React components become more maintainable and type-safe:

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

Impact on Development Workflow

This type transformation system fundamentally improves the developer experience:

Performance Benefits

  • Zero runtime cost: All transformations happen at compile-time

  • Smaller bundle size: No runtime type checking or transformation libraries

  • Better tree-shaking: Cleaner types improve dead code elimination

Developer Experience

  • Predictable APIs: Components work with clean, expected data structures

  • Better IntelliSense: Full autocomplete for nested properties

  • Compile-time safety: Catch reference resolution errors during build

  • Cleaner code: No manual casting or type guards needed

Maintainability

  • Centralized mapping: All reference types managed in one location

  • Automatic updates: Adding new content types requires minimal changes

  • Clear separation: Clean boundary between CMS data layer and application logic

Conclusion

This TypeScript type transformation approach solves the fundamental mismatch between Sanity's generated types and frontend application needs. By leveraging advanced TypeScript features, we create a zero-cost abstraction that provides clean, type-safe data structures while maintaining the flexibility of a dynamic page builder system.

The solution demonstrates how sophisticated type-level programming can solve real-world problems, creating better developer experiences without sacrificing performance or maintainability. For teams building complex, dynamic content systems with Sanity CMS, this pattern provides a robust foundation for scalable, type-safe frontend development.

Resources

Want to stay updated via WhatsApp?

Stay ahead with METYCLE's products! Join our WhatsApp broadcast channel to receive real-time updates on available metals and unbeatable deals. Explore our current product offerings and never miss out on the best opportunities!