2 Comments

The Single-Valued Type Pattern for TypeScript

We’ve been using TypeScript heavily over the last few years at Atomic, and a pattern has emerged for how we deal with cases where we want flexible, composable APIs for dealing with statically-known concerns about an application.

Our Wish List

Whether it’s data repositories, CMS content types, GraphQL queries, or background jobs, situations regularly arise where you need to design a solution to a problem with the following properties:

  • You want to build generic infrastructure for dealing with a whole class of a problem, with fine-grained static typing for the instances.
  • You will have many varying instances of the problem, warranting a little extra investment in API design and typing.
  • Those instances vary in runtime behavior and also statically, with respect to the types each individual instance deals with.
  • You want to enable flexible APIs and layering to build nice abstractions for dealing with this concern.

We’ve evolved a powerful technique for solving problems like these, what we’ve been calling the single-valued type pattern.

The Single-Valued Type Pattern

At its heart, the pattern involves creating flexible generic APIs by pulling runtime and type variations into a constant that’s statically passed into related functions at every call site. For each example in a class of problems, we create a single constant that bundles up the type and runtime specifics of that example. By combining the M instances in our class with the N functions, React hooks, and other APIs, the power and expressiveness of our system grows quickly.

For example, in a React Native app currently under development, we’re using Contentstack as our headless CMS. Each CMS content type we define has whatever fields we specify and provides an interface for the content to be edited and published. We wanted a nice typesafe way of pulling content down into our application.

For each content type we add to the CMS, we define a constant in our code that captures the type of the content and a recipe for locating it:


type SplashContentData = {
  title: string;
  welcomeSubhead: string;
  image: ImageContent;
  body: string;
};

export const SplashContent = declareContentType<SplashContentData>({
  id: 'splash',
  buildQuery: (stack) =>
    stack.ContentType('splash').Entry(...),
  extractContent: (entry) => {
    return {
      title: entry.get('title'),
      welcomeSubhead: entry.get('welcome_subhead'),
      image: entry.get('image'),
      body: entry.get('body'),
    };
  },
});

The SplashContent constant is the single-valued type in this example. It ties the known type of our splash content (passed in via explicit type argument) to some runtime data for fetching the data from the CMS and transforming it into the result type. The id field gives us a useful way of uniquely identify our ContentType values in a Map or other generic structure.

SplashContent can then be passed into a family of React hooks and components to cleanly fetch the data. For example, one use of our React hook looks like this:


const cmsContent = useCmsContent({contentType: SplashContent});

if (cmsContent.data) {
    return <Text>{cmsContent.data.title}</Text>
}

The useCmsContent hook infers its types from its contentType argument, giving us precise static typing over the type of data we’re dealing with. It uses the runtime data structure to know the specifics of how to get that data. Taken together, useCmsContent is precisely defined for each CMS content type without the duplication of separate function definitions.

Applying the Single-Valued Type Pattern

1. Create a Generic Type

The first step is to create a generic type to house your runtime and type data. For example, our CMS type is defined as:


export type ContentStackContentType<TContent = any, TArgument = any> = {
  id: string;
  buildQuery: (stack: Stack, arg: TArgument) => Entry;
  extractContent: (entry: any) => TContent;

  /** Don't use this directly, prefer `ArgType<T>`. Phantom property to hold the argument type. Doesn't exist at runtime */
  TArgument: TArgument;
  /** Don't use this directly, prefer `ContentType<T>`. Phantom property to hold the argument type. Doesn't exist at runtime */
  TContent: TContent;
};

A few tips on designing this type:

  • It can have as many generic type arguments as you wish. We commonly default them all to any to make it easy to define functions that deal with instances of this type, as you’ll see below.
  • Add phantom properties to the type to capture the generic type arguments for easy extractions later and to ensure the type isn’t “lost” due to lack of use. We use naming conventions to remind ourselves these don’t exist at runtime.
  • It’s often useful to include some sort of string or string-literal identifier to aid in logging, generating lookup keys, etc.

2. Create a Declaration Function

While there may not be any actual runtime work that needs to happen, the use of phantom type properties means you can’t actually create these values as literal data structures. We usually define a function that aids in creating instances of our type, for example:


export function declareContentType<TContent, TArgument = undefined>(args: {
  id: string;
  buildQuery: (stack: Stack, arg: TArgument) => Entry;
  extractContent: (entry: any) => TContent;
}): ContentStackContentType<TContent, TArgument> {
  return args as any;
}

In our case, the function doesn’t do anything but cast to the target type. Even though it’s not doing much now, the function keeps the cast isolated to one spot in the code and gives us a place to enrich the definition API to support alternate formulations in the future.

This function likely needs the same type parameters as the underlying type, as it’s responsible for assigning types to the values.

3. Create Related Utility Types

We usually end up defining some supporting types and helpers. At the very least, these usually include types that streamline extraction of the types from specific instances:


export type ContentType<T extends ContentStackContentType> = T['TContent'];
export type ArgType<T extends ContentStackContentType> = T['TArgument'];

These definitions simply get the content and argument types back out of our CMS content. Defining type-safe APIs to deal with instances of our type all look like the above. We define a generic with a variable that extends the base type (with any for any type arguments to admit any instance). We can then use type info from the specific argument to precisely define the types of our API, in this case simply extracting them for use elsewhere.

Note that providing any as the default to our base type in step 1 is what allows us to use it with no arguments and an extends constraint on any related API, allowing those APIs to take on the specifics of any particular use while keeping definitions decoupled from the actual number of type arguments. We can vary the number of type arguments to ContentStackContentType without having to update most uses.

We can use these to define more specialized related types, such as the CMS lookup result type returned by our React hook:


export type CmsContentResult<T extends ContentStackContentType> = {
  loading: boolean;
  data?: ContentType<T>;
  error?: Error;
};

Define Your APIs

With the above groundwork laid, we can now define APIs for dealing with the type. For example, our React hook is typed as:


export function useCmsContent<TCT extends ContentStackContentType>(
  args: TypeAndArgObject<TCT>,
): CmsContentResult<TCT> {
  ...
}

where TypeAndArgObject is defined as:


export type TypeAndArgObject<
  T extends ContentStackContentType = ContentStackContentType
> = T extends ContentStackContentType<any, undefined>
  ? {contentType: T; arg?: undefined}
  : {contentType: T; arg: ArgType<T>};

Atop this hook, we’ve also created a handful of generic React components that use the hook to fetch and extract content to show, such as:


  <CmsText
    style={[styles.subheader, styles.header]}
    contentType={SplashContent}
    field="title"
    testID="cms-title"
  />

These components are also generic functions parameterized with a ContentStackContentType so there is precise typing on them. field will only admit string names of the underlying typescript type, for example.

Repeat and Expand

With this basis laid, you’ve got the foundations for an API that can grow gracefully over time and scale to meet unexpected challenges. You tend to start creating many instances of the single-valued type as, for example, you add more content to your app. But as time goes on, you’ll also occasionally add new utilities for dealing with those instances, multiplying out your investment in the pattern.

While the individual values are precisely typed and usually passed in directly at call sites, we often aggregate them to do things with all the instances at once, such as an array of “all test CMS content” that provides example data for the instances needed by our integration tests. Or we’ll bundle them at runtime — our hook uses a simple map at runtime to cache all CMS content in one loosely-typed collection, while relying on precise typing of the APIs to make type errors impossible.

This ability to have precise typing of specific instances while also dealing with aggregates gives the technique a lot of power. In practice, the vast majority of code intersecting a particular concern is statically typed and flexible. But implementing the machinery underpinning it is also pretty straightforward as well, as it can be designed in terms of the base super-type.