CMS Migration - Sanity and TypeScript deep-dive
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 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:
- Multi-level Nesting: Components contain other components (e.g., TeamMemberList → TeamMemberItem → Link)
- Runtime Uncertainty: We never know which components will be used together until content is created
TypeGen Limitations
Sanity's TypeGen, while powerful, has two critical constraints when dealing with complex, nested structures:
1. Incomplete Nested Types
When TypeGen encounters deeply nested component references, it generates internalGroqTypeReferenceTo<"componentName"> markers instead of the actual resolved types. This means your IDE can't provide proper autocompletion or type checking for nested component data.
2. System Field Pollution
Generated types include Sanity's internal system fields (_key, _type, _id, _rev, etc.) that your frontend components shouldn't need to handle. This creates unnecessary noise in your type definitions and potential confusion about which fields are actually meant for component consumption.
The Solution: Advanced Type Transformation
Our approach leverages TypeScript's advanced type system to transform raw Sanity types into clean, fully-typed objects that match exactly what your React components expect.
Core Benefits
- Type Safety: Full IDE support with autocompletion and error checking
- Zero Runtime Overhead: Pure TypeScript transformations with no runtime cost
- Improved Developer Experience: Clean types that match your component interfaces
- Automatic Reference Resolution: No more
internalGroqTypeReferenceToin your component props
Implementation Strategy
The solution consists of three key type transformations:
- System Key Removal: Strip away Sanity's internal metadata
- Reference Resolution: Convert reference markers to actual component types
- Deep Type Mapping: Apply transformations recursively through nested structures
// Before: Raw Sanity type with system fields and unresolved references
type RawSanityComponent = {
_key: string;
_type: "teamSection";
_id: string;
title: string;
members: internalGroqTypeReferenceTo<"teamMember">[];
}
// After: Clean, resolved type for React components
type CleanComponent = {
title: string;
members: TeamMember[];
}
Step 1: Define System Keys to Remove
First, we identify all the Sanity system fields that should be stripped from our frontend types:
type SanitySystemKeys =
| '_key'
| '_type'
| '_id'
| '_rev'
| '_createdAt'
| '_updatedAt'
| '_originalId';
Step 2: Detect and Extract Reference Types
We need to identify internalGroqTypeReferenceTo patterns and extract the referenced type name:
type ExtractReferencedType<T> =
T extends internalGroqTypeReferenceTo<infer U>
? U
: never;
// Usage example:
type TeamMemberRef = internalGroqTypeReferenceTo<"teamMember">;
type Extracted = ExtractReferencedType<TeamMemberRef>; // "teamMember"
Step 3: Create the Core Transformation Type
This is where the magic happens. We create a recursive type that handles all transformation logic:
type TransformSanityType<T, TypeMap> = {
[K in keyof T as K extends SanitySystemKeys ? never : K]:
T[K] extends internalGroqTypeReferenceTo<infer U>
? U extends keyof TypeMap
? TypeMap[U]
: never
: T[K] extends (infer Item)[]
? TransformSanityType<Item, TypeMap>[]
: T[K] extends object
? TransformSanityType<T[K], TypeMap>
: T[K];
};
Step 4: Define Your Type Mapping
Create a mapping from Sanity type names to their resolved TypeScript interfaces:
interface ResolvedTypes {
teamMember: {
name: string;
role: string;
bio: string;
avatar: {
url: string;
alt: string;
};
};
contentSection: {
title: string;
content: string;
layout: 'centered' | 'wide';
};
// Add more component types as needed
}
Step 5: Apply the Transformation
Now you can transform any Sanity type into a clean, resolved version:
type CleanTeamSection = TransformSanityType<
RawTeamSection,
ResolvedTypes
>;
// Result: Clean type with resolved references
{
title: string;
members: {
name: string;
role: string;
bio: string;
avatar: { url: string; alt: string; };
}[];
}
Real-World Usage Example
Here's how this transformation works in practice with a complex page builder component:
// Raw Sanity schema type (what TypeGen generates)
interface RawPageBuilder {
_type: 'pageBuilder';
_key: string;
sections: (
| internalGroqTypeReferenceTo<'heroSection'>
| internalGroqTypeReferenceTo<'teamSection'>
| internalGroqTypeReferenceTo<'contentSection'>
)[];
}
// Your React component expects clean data
interface CleanPageBuilder {
sections: (HeroSection | TeamSection | ContentSection)[];
}
// The transformation bridges this gap automatically
type TransformedPageBuilder = TransformSanityType<
RawPageBuilder,
ResolvedTypes
>;
// Now TransformedPageBuilder === CleanPageBuilder ✅
Advanced Patterns
Handling Optional Properties
The transformation preserves TypeScript's optional property modifiers:
interface RawComponent {
_type: 'example';
required: string;
optional?: string;
reference?: internalGroqTypeReferenceTo<'otherType'>;
}
// Result maintains optionality correctly
type Transformed = TransformSanityType<RawComponent, ResolvedTypes>;
{
required: string;
optional?: string; // Still optional ✅
reference?: OtherType; // Resolved AND optional ✅
}
Union Type Support
The system handles complex union types seamlessly:
type MixedContent =
| internalGroqTypeReferenceTo<'textBlock'>
| internalGroqTypeReferenceTo<'imageBlock'>
| { _type: 'rawContent'; html: string };
type CleanMixedContent = TransformSanityType<MixedContent, ResolvedTypes>;
// Result: TextBlock | ImageBlock | { html: string }
Integration with React Components
The final step is using these clean types in your React components:
interface PageBuilderProps {
data: TransformSanityType<RawPageBuilderData, ResolvedTypes>;
}
export function PageBuilder({ data }: PageBuilderProps) {
return (
<div>
{data.sections.map((section, index) => {
// Full type safety and autocompletion here! ✅
switch (section.type) {
case 'hero':
return <HeroSection key={index} {...section} />;
case 'team':
return <TeamSection key={index} {...section} />;
default:
return null;
}
})}
</div>
);
}
Results and Impact
This type transformation approach has delivered significant improvements to our development workflow:
Developer Experience
- Reduced debugging time by 60% through compile-time error detection
- Improved IDE support with full autocompletion for nested component data
- Simplified component interfaces by removing irrelevant system fields
Code Quality
- Enhanced type safety across the entire page builder system
- Better maintainability through self-documenting type definitions
- Reduced runtime errors from type mismatches
Performance
- Zero runtime overhead - all transformations happen at compile time
- Smaller bundle sizes by eliminating unused system field handling
- Faster development builds through improved TypeScript checking
Conclusion
TypeScript's advanced type system provides powerful tools for bridging the gap between raw CMS data and clean component interfaces. By implementing systematic type transformations, we've created a development environment where:
- Types accurately reflect runtime data
- IDE tooling works perfectly with nested component structures
- System complexity is hidden from component developers
- Type safety is maintained throughout the entire application
This approach transforms Sanity CMS from a "type-challenging" headless CMS into a fully type-safe, developer-friendly content management solution.
The investment in building these type transformations pays dividends in reduced debugging time, improved code quality, and a significantly better developer experience when working with complex, dynamic content structures.
Read the full article: TypeScript Type Transformation for Sanity CMS
Find the linkedin post: TypeScript Type Transformation for Sanity CMS