Content relationship types within another content relationship in a Slice

Hello, Prismic People :grin:!

Some time ago, I encountered issues with Content Relationship types in a Slice. However, I managed to resolve them by following the solution provided by angelsashmore, which I found in the responses to the topic created by Joseph.

Topic:
:point_right: Types for content relationship in a Slice

And also in the documentation:
:point_right: Use TypeScript with Next.js

:man_technologist: Upon exploring the solution further, I managed to condense the code and arrive at the following:

import * as prismicH from "@prismicio/helpers";
import * as prismicT from "@prismicio/types";

const hasServiceData = <
  TContentRelationshipField extends prismicT.ContentRelationshipField
>(
  contentRelationshipField: TContentRelationshipField
): contentRelationshipField is TContentRelationshipField &
  Content.ServiceDocument => {
  return (
    prismicH.isFilled.contentRelationship(contentRelationshipField) &&
    typeof contentRelationshipField.data === "object" &&
    contentRelationshipField.data !== null &&
    contentRelationshipField.id !== null &&
    contentRelationshipField.uid !== null
  );
};

// Example of usage within a Slice function
hasServiceData(slice.items[0].service) &&
    console.log(
      slice.items[0].service.data.title,
      slice.items[0].service.id,
      slice.items[0].service.uid,
      slice.items[0].service.data.testimonials[0]?.author
    );

"If everything went well, then what's the problem :face_with_monocle:?"

The problem is that you don't have just one Content Relationship, but a Content Relationship (case_study) within another Content Relationship (service).

So the current solution works for all the other fields, but when it gets to the innermost Content Relationship, it stops there and doesn't do the typing.

I tried to change everything to see what could be done, but I only found two ways to solve this, the first would be to create another function just for this internal Content Relationship, and the second would be to do it more "by hand", that is, uid: uid , id: id and so on until you arrive.

The first solution is acceptable, but I would like to keep everything in just one function, and the second solution I didn't find very interesting, as it would be a more "manual" process, something that would get even worse the more and more fields were added.

:man_technologist: The code for the second solution would look something like this:

const hasServiceData = <
  TContentRelationshipField extends prismicT.ContentRelationshipField
>(
  contentRelationshipField: TContentRelationshipField
): contentRelationshipField is TContentRelationshipField & {
  id: Content.ServiceDocument["id"];
  uid: Content.ServiceDocument["uid"];
  data: {
    title: Content.ServiceDocumentData["title"];
    header_image: Content.ServiceDocumentData["header_image"];
    tag: Content.ServiceDocumentData["tag"];
    color: Content.ServiceDocumentData["color"];
    description: Content.ServiceDocumentData["description"];
    headline: Content.ServiceDocumentData["headline"];
    testimonials: Content.ServiceDocumentData["testimonials"];
    highlights: Content.ServiceDocumentData["highlights"];
    case_studies: { case_study: Content.CaseStudyDocument }[];
  };
} => {
  return (
    prismicH.isFilled.contentRelationship(contentRelationshipField) &&
    typeof contentRelationshipField.data === "object" &&
    contentRelationshipField.data !== null &&
    contentRelationshipField.id !== null &&
    contentRelationshipField.uid !== null
  );
};

So, do you guys have any ideas for a third solution that is more 'elegant'? Or do you think that the two proposals mentioned above are the only ways to infer types from a Content Relationship nested within another Content Relationship in a Slice?

Hi @gabrielv,

This is a fairly complex problem that currently doesn't have an official solution. However, I wrote a helper that I think may help in your case.

It still requires manually listing out which fields you want typed at compile time and checked at run time, but it should reduce the amount of code you need to write.

// src/lib/isFilledRelatedData.ts

import {
  Content,
  FilledContentRelationshipField,
  LinkField,
  isFilled,
} from "@prismicio/client";

type DocumentData<TDocumentType extends Content.AllDocumentTypes["type"]> =
  Extract<Content.AllDocumentTypes, { type: TDocumentType }>["data"];

export function isFilledRelatedData<
  TDocumentType extends Content.AllDocumentTypes["type"],
  TFieldID extends keyof DocumentData<TDocumentType>,
>(
  linkField: LinkField,
  documentType: TDocumentType,
  fieldID: TFieldID,
): linkField is FilledContentRelationshipField & {
  data: {
    [P in keyof DocumentData<TDocumentType> as P extends TFieldID
      ? P
      : never]: DocumentData<TDocumentType>[P];
  };
} {
  return (
    isFilled.contentRelationship(linkField) &&
    linkField.type === documentType &&
    typeof linkField.data === "object" &&
    linkField.data !== null &&
    fieldID in linkField.data
  );
}

You can use it like this:

if (
  isFilledRelatedData(slice.primary.button_link, "page", "meta_title")
) {
  slice.primary.button_link.data.meta_title
  // ^ Typed as Content.PageDocumentData["meta_title"]
}

Or if you need to check and type nested related data:

if (
  isFilledRelatedData(slice.primary.button_link, "page", "parent") &&
  isFilledRelatedData(slice.primary.button_link.data.parent, "page", "meta_title" )
) {
  slice.primary.button_link.data.parent.data.meta_title
  // ^ Typed as Content.PageDocumentData["meta_title"]
}

Using this pattern, you can write your own helper functions specific to your project. How you build that will depend on your content models.

In the future, we may provide a helper like this in @prismicio/client. For now, you can copy this code into your project.

1 Like

Edit: more importantly, do I have to use it out of mapSliceZone, and when in the slice component, do I have to use this type mapped once again?

This is really good, but when I try to use this with mapSliceZone, it doesn't work. The content group slice type doesn't appear for mappedSlices

	const mappedSlices = await mapSliceZone(page.data.slices, {
		content_group: ({ slice }) => {
			return {
				...slice,
				primary: {
					group: slice.primary.group.map((group) => {
						if (isFilledRelatedData(group.link, 'blog', 'featured_image')) {
							return {
								label: group.label,
								link: group.link
							};
						}
						return group;
					})
				}
			};
		}
	});

Hi @aadtyaraj01,

The snippet shared by Angelo should work in mapSliceZone,

I believe TypeScript simplifies your type to just { label: KeyText; link: LinkField } because there's a case where you don't infer the type and just return the group as-is (which is a bit unsettling from TypeScript, I agree as I'd expect too TypeScript to return a union of both).

A small hack that can be used is to mark the first return statement as readonly. This seems to force TypeScript to create a union:

const mappedSlices = await mapSliceZone(page.data.slices, {
  content_group: ({ slice }) => {
    return {
      ...slice,
      primary: {
        group: slice.primary.group.map((group) => {
          if (isFilledRelatedData(group.link, 'blog', 'featured_image')) {
            return {
              label: group.label,
              link: group.link
            } as const; // Here! Noticed the added `as const`
          }
          return group;
        })
      }
    };
  }
});

This should allow you later down the road to differentiate the data you're working with.

However, I believe it could make more sense to only use this helper when working within your components in your case. Indeed, doing it like with the above slice mapper will ultimately force you to, once again, make use of isFilledRelatedData in your components.

Let me know if that's clear to you!