Prompt Template 13: Complex Data Mapper
Template for writing complex mapper functions that transform nested Umbraco API responses into typed component props.
1. Context Loading
Section titled “1. Context Loading”Read these files before starting:
docs/prd/08_API_CONTRACTS.mddocs/dev-frontend-guides/03_COMPONENT_ARCHITECTURE.md (mapper section)packages/cms-client/src/types.tspackages/cms-client/src/mappers/ (existing mapper examples)2. Prompt Template
Section titled “2. Prompt Template”I need a complex data mapper for the {MODULE_NAME} module ({MODULE_ALIAS}).
## Umbraco Element Type
- Content type alias: `{ELEMENT_TYPE_ALIAS}`- This element appears in: {CONTEXT} (e.g., "a Block List on the Home Page content type")
## Raw API JSON Response
Here is a real example of the API response from Umbraco Content Delivery API v2:
```json{RAW_JSON_RESPONSE}Target TypeScript Props Interface
Section titled “Target TypeScript Props Interface”The component expects these props:
{TARGET_PROPS_INTERFACE}Note: siteKey and locale are injected by ModuleRenderer and must NOT be
included in the mapper return type. The mapper returns
Omit<{COMPONENT_PROPS_TYPE}, 'siteKey' | 'locale'>.
Nested Structures
Section titled “Nested Structures”{NESTED_STRUCTURE_DESCRIPTION}
Describe any Block Lists within Block Lists, e.g.:
- Top level: array of offer items (Block List)
- Each offer item contains: an image gallery (Block List of media elements)
- Each gallery item contains: responsive images (imageDesktop + imageMobile)
Field Mapping Rules
Section titled “Field Mapping Rules”| Umbraco Field | Type in API | Required? | Default Value | Target Prop |
|---|---|---|---|---|
| {UMBRACO_FIELD_1} | {API_TYPE} | {YES/NO} | {DEFAULT} | {PROP_NAME} |
| {UMBRACO_FIELD_2} | {API_TYPE} | {YES/NO} | {DEFAULT} | {PROP_NAME} |
| … | … | … | … | … |
Special Transformations
Section titled “Special Transformations”- Media resolution: imageDesktop + imageMobile pattern; resolve URLs with locale
awareness. Umbraco focal points
{ top: 0.3, left: 0.5 }must convert to CSSobject-position: 50% 30%. - URL transformations: {URL_RULES} (e.g., internal links need locale prefix)
- Date formatting: {DATE_RULES} (e.g., ISO string to “dd MMM yyyy” display format)
- CTA / link objects: map
nametolabel,urltohref,targettotarget - Currency / price: {PRICE_RULES} (e.g., cents to formatted string with currency symbol)
Requirements
Section titled “Requirements”- Fully typed: no
anyin the final code - Null-safe for every optional Umbraco property
- Sensible defaults for missing values
- Empty Block Lists default to
[] - Missing nested objects handled gracefully
- Media resolved with locale fallback
- Write unit tests covering all branches
---
## 3. Acceptance Criteria
- [ ] Mapper function signature: `(element: UmbracoElement) => Omit<ComponentProps, 'siteKey' | 'locale'>`- [ ] No `any` type in the final code (use `unknown` + type guards if needed)- [ ] Every optional Umbraco property is null-checked before access- [ ] Default values provided for all optional fields (strings default to `''`, arrays to `[]`, booleans to `false`, numbers to `0` unless otherwise specified)- [ ] Empty or missing Block Lists result in `[]`, not `undefined` or `null`- [ ] Missing nested objects do not cause runtime errors- [ ] Media URLs resolved correctly, including locale-aware fallback- [ ] Focal points converted from `{ top, left }` to `object-position` CSS value- [ ] CTA/link objects mapped: `name` to `label`, `url` to `href`- [ ] Date strings parsed and formatted per spec- [ ] Unit tests cover: happy path, missing optional fields, empty arrays, null nested objects, missing media, edge-case dates- [ ] Mapper exported and registered in the mapper registry
---
## 4. Common Pitfalls
1. **Using `any` without narrowing.** Umbraco API responses are loosely typed. Always narrow with type guards or optional chaining, never leave `any` in the final code.
2. **Not handling null/undefined for optional Umbraco properties.** Umbraco returns `null` for empty fields, not `undefined`. Check for both.
3. **Forgetting to map CTA/link objects.** Umbraco links have `name` and `url`; components expect `label` and `href`. This mismatch causes silent bugs.
4. **Not defaulting empty Block Lists to `[]`.** If a Block List is empty, Umbraco may return `null` or omit the property entirely. Always default to `[]`.
5. **Assuming media always has width/height.** Umbraco media may lack dimensions. Provide fallback values or make them optional in the target interface.
6. **Forgetting locale-aware media resolution.** Media URLs may differ per locale. Use the media resolver utility, not raw URL strings.
7. **Not handling nested Block Lists.** When Block Lists contain Block Lists, each level needs its own null-safe iteration and mapping.
8. **Incorrect focal point conversion.** Umbraco uses `{ top, left }` with values 0-1. CSS object-position uses `left% top%` (note the reversed order). `{ top: 0.3, left: 0.5 }` becomes `object-position: 50% 30%`.
---
## 5. Example
### M19 Offers Carousel Mapper
**Element type alias:** `m19OffersCarousel`
**Nested structure:**- Top level: `offers` Block List containing offer item elements- Each offer item: responsive images (imageDesktop + imageMobile), date range, price, CTA, category tags, optional promo badge
**Raw API response (abridged):**
```json{ "contentType": "m19OffersCarousel", "properties": { "heading": "Exclusive Offers", "subheading": "Discover our seasonal packages", "offers": { "items": [ { "content": { "contentType": "m19OfferItem", "properties": { "title": "Summer Escape", "description": "<p>Enjoy a luxurious summer getaway...</p>", "imageDesktop": [{ "url": "/media/offers/summer-desktop.jpg", "name": "Summer", "width": 1200, "height": 800, "focalPoint": { "top": 0.4, "left": 0.5 } }], "imageMobile": [{ "url": "/media/offers/summer-mobile.jpg", "name": "Summer", "width": 600, "height": 800, "focalPoint": { "top": 0.3, "left": 0.5 } }], "checkInDate": "2026-06-01T00:00:00", "checkOutDate": "2026-09-30T00:00:00", "priceAmount": 350, "priceCurrency": "EUR", "priceLabel": "per night", "cta": { "name": "Book Now", "url": "/pt/reservas?offer=summer-escape", "target": "_self" }, "categories": ["Spa", "Romance"], "promoBadge": "20% Off" } } }, { "content": { "contentType": "m19OfferItem", "properties": { "title": "Weekend Retreat", "description": null, "imageDesktop": [{ "url": "/media/offers/weekend-desktop.jpg", "name": "Weekend", "width": 1200, "height": 800, "focalPoint": null }], "imageMobile": [], "checkInDate": "2026-04-01T00:00:00", "checkOutDate": null, "priceAmount": null, "priceCurrency": null, "priceLabel": null, "cta": { "name": "Learn More", "url": "/pt/ofertas/weekend-retreat", "target": "_self" }, "categories": [], "promoBadge": null } } } ] } }}Target props interface:
interface M19OffersCarouselProps { siteKey: string; locale: string; heading: string; subheading: string; offers: OfferItem[];}
interface OfferItem { title: string; description: string; imageDesktop: ResponsiveImage | null; imageMobile: ResponsiveImage | null; dateRange: string; price: string; cta: CTALink | null; categories: string[]; promoBadge: string | null;}
interface ResponsiveImage { url: string; alt: string; width: number; height: number; focalPosition: string;}
interface CTALink { label: string; href: string; target: string;}Resulting mapper (abridged):
import { UmbracoElement } from '../types';import { resolveMediaUrl, formatDateRange, formatPrice } from '../utils';
type M19MapperResult = Omit<M19OffersCarouselProps, 'siteKey' | 'locale'>;
function mapMedia(media: unknown[] | null | undefined): ResponsiveImage | null { if (!media || media.length === 0) return null; const item = media[0] as Record<string, unknown>; const focal = item.focalPoint as { top: number; left: number } | null; return { url: resolveMediaUrl(String(item.url ?? '')), alt: String(item.name ?? ''), width: Number(item.width ?? 0), height: Number(item.height ?? 0), focalPosition: focal ? `${Math.round(focal.left * 100)}% ${Math.round(focal.top * 100)}%` : '50% 50%', };}
function mapCTA(cta: unknown): CTALink | null { if (!cta || typeof cta !== 'object') return null; const link = cta as Record<string, unknown>; return { label: String(link.name ?? ''), href: String(link.url ?? ''), target: String(link.target ?? '_self'), };}
export function mapM19OffersCarousel(element: UmbracoElement): M19MapperResult { const props = element.properties ?? {};
const rawOffers = (props.offers as { items?: unknown[] })?.items ?? [];
const offers: OfferItem[] = rawOffers.map((raw: unknown) => { const item = (raw as { content?: { properties?: Record<string, unknown> } }) ?.content?.properties ?? {};
return { title: String(item.title ?? ''), description: String(item.description ?? ''), imageDesktop: mapMedia(item.imageDesktop as unknown[] | null), imageMobile: mapMedia(item.imageMobile as unknown[] | null), dateRange: formatDateRange( item.checkInDate as string | null, item.checkOutDate as string | null ), price: formatPrice( item.priceAmount as number | null, item.priceCurrency as string | null, item.priceLabel as string | null ), cta: mapCTA(item.cta), categories: Array.isArray(item.categories) ? item.categories.map(String) : [], promoBadge: item.promoBadge ? String(item.promoBadge) : null, }; });
return { heading: String(props.heading ?? ''), subheading: String(props.subheading ?? ''), offers, };}Key decisions in this example:
mapMediareturnsnullwhen the array is empty (weekend offer has no mobile image)- Focal point defaults to
50% 50%when missing descriptiondefaults to empty string even when Umbraco returnsnullpromoBadgepreservesnull(component hides badge when null)categoriesdefaults to[]when empty or missingdateRangeandpriceuse utility formatters that handle null inputs gracefully