Skip to content

Prompt Template 13: Complex Data Mapper

Template for writing complex mapper functions that transform nested Umbraco API responses into typed component props.


Read these files before starting:

docs/prd/08_API_CONTRACTS.md
docs/dev-frontend-guides/03_COMPONENT_ARCHITECTURE.md (mapper section)
packages/cms-client/src/types.ts
packages/cms-client/src/mappers/ (existing mapper examples)

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}

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_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)
Umbraco FieldType in APIRequired?Default ValueTarget Prop
{UMBRACO_FIELD_1}{API_TYPE}{YES/NO}{DEFAULT}{PROP_NAME}
{UMBRACO_FIELD_2}{API_TYPE}{YES/NO}{DEFAULT}{PROP_NAME}
  • Media resolution: imageDesktop + imageMobile pattern; resolve URLs with locale awareness. Umbraco focal points { top: 0.3, left: 0.5 } must convert to CSS object-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 name to label, url to href, target to target
  • Currency / price: {PRICE_RULES} (e.g., cents to formatted string with currency symbol)
  • Fully typed: no any in 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:

  • mapMedia returns null when the array is empty (weekend offer has no mobile image)
  • Focal point defaults to 50% 50% when missing
  • description defaults to empty string even when Umbraco returns null
  • promoBadge preserves null (component hides badge when null)
  • categories defaults to [] when empty or missing
  • dateRange and price use utility formatters that handle null inputs gracefully