Home

Seamless Type Generation Increases Developer Productivity and Confidence

Discover an innovative way to integrate front-end types into back-end generation, simplifying content transformation and improving developer experience.

Tools that help developers work with content in composable websites often boast "automatic type generation" as one of their features. That sounds super convenient, right?

I query content in some way and then I have a type ready to go. I have an immediate confidence boost without writing runtime tests.

const post = await client.findOne("Post", "123");

If using an IDE like VS Code, you can inspect the return type of the query, which might be something like this:

type Post = {
title: string;
slug: string;
content: string;
};

You get the benefits of typeahead properties and can inspect types for properties. For example, if you inspect post.title you might see something like:

(property) title: string

Again, this seems great! And it does go a long way in speeding up the development process.

The limitations of content source types generation

The problem is that the shape of content coming from the content source is often not exactly what we need for our front-end pages and components.

Take our post example. It has three properties that could be enough to build out a basic blog. But what if your front end also expects an excerpt property, which it can use when a card or snippet of the post is rendered?

Perhaps there is an optional excerpt property in the content source, but it's optional.

type Post = {
title: string;
slug: string;
excerpt?: string;
content: string;
};

But your front-end Card component doesn't want it to be optional. And furthermore, it's a card, not a post, so it wants a properties called heading and body.

A quick fix to transform content shapes for components

These are relatively quick transformations we can make.

function getPostCardProps(post: Post): CardProps {
return {
heading: post.title,
body: document.excerpt || `${document.content?.slice(0, 100)}...`,
};
}

Simple, right?

Minimizing transformer logic calls in front-end code

But where do you put that code? Do you really want to call getPostCardProps on a Post object every time you need to render the post as a card?

That may work for small projects, but I've seen it get out of hand quickly.

All of a sudden an entire front-end codebase becomes littered with transformation utility function calls. Then there's the business of keeping transformation functions organized, which is another challenge.

Transforming content when retrieving content

Another popular approach is to transform all the content when it is retrieved from the content source.

function transformPost(post: Post) {
return {
...post,
card: getPostCardProps(post),
};
}

Then you'd have a nice packaged card property that has the props for the card. Great, except what's the return type here?

Now you need to manually define an intermediary type.

interface TransformedPost extends Post {
card: CardProps;
}

function transformPost(post: Post): TransformedPost {
return { ...post, card: getPostCardProps(post) };
}

It seems okay on a small scale like this, but it can quickly become cumbersome.

Embedding front-end types in generated back-end types

I have an idea. I think we can have the same mechanism that loads content from the source be responsible for transforming the content and generating type definitions that play nice with the front end.

It works like this:

  • The schema for the content source is defined by code
  • That schema definition includes the necessary transformation functions
  • Transformation functions come with configuration to tell the mechanism what the return type should be
  • The mechanism uses this information to do all the work.

Prototype of the schema definition

The schema definition might look something like this:

import { type Post } from "@/path/to/generated-types.d";
import { definePageModel, defineStringField } from "my-mechanism";

export const PostModel = definePageModel<Post>({
name: "Post",
fields: [
defineStringField("title", { required: true }),
defineStringField("slug", { required: true }),
defineStringField("excerpt"),
defineStringField("content", { required: true }),
],
methods: {
card: {
outputType: "Component.CardProps",
async: true,
resolve: async ({ sys: { document } }) => {
return {
heading: document.title,
body: document.excerpt || document.content?.slice(0, 100) + "...",
};
},
},
},
});

Note that Component.CardProps would be included as an import to the generated types file through some configuration of the mechanism.

A look at the generated TypeScript type definitions

The generated types might look something like this:

export interface Post extends Document<"Post", "card"> {
title: string;
slug: string;
excerpt?: string;
content: string;
card: DocumentMethod<Promise<Component.CardProps>>;
}

Where Document and DocumentMethod are utility types that come from the system and are prepped for adding system-level information when processing the sourced content.

Working with generated types through the client

And then you'd call the client, and could use the returned object to get the card properties with the defined CardProps type:

const post = await client.findOne("Post", "123");
const card = await post.card(); // returns Component.CardProps

Testing with a prototype: navigating the compile-time/runtime predicament

I've put together a prototype of this system, and after many iterations, actually have something working.

The most challenging part of the process is doing the dance between compile-time and runtime. It's still not the best developer experience, but it's been a really interesting problem to solve.

Take these two lines in the schema definition:

import { type Post } from "@/path/to/generated-types.d";

export const PostModel = definePageModel<Post>({

Post is generated by the system, but it's also used by the schema definition to provide a better DX through strongly-typed return types on dynamically-defined methods.

So, initially, when developing a method, the type doesn't have that method. But once the generator gets run once, it's there, and the experience improves.

Eliminating the need for intermediary transformers

This removes the need for introducing an abundance of utility methods or the need for manually-defined and manipulated content source types.

Could this be the future of working with content?

I'm pretty excited about where this could go, but ... is it just me? Would you use something like this? Share you thoughts with me! You can find my links on GitHub.

Let's Connect

Keep Reading

Mapping Dynamic Object Keys in TypeScript

Dynamic property maps are a super powerful paradigm in JavaScript, but they can be tricky to type correctly with TypeScript.

May 20, 2022

Generating Workable TypeScript Types from Contentful Content

Automatically generate TypeScript type definitions from a Contentful schema, and then override for front-end adjustments.

May 03, 2023

Creating Parity Between CI and Jamstack Deployment Process

You can get to production faster by separating CI and build/deployment process. But there’s risk in it. Here are two approaches to help with the decision.

Jul 01, 2022