Article summary
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.
Cool pattern 👍 Thanks for sharing. I have never used phantom properties before, so that’s a new thing for me, and worth to keep in the back of my head
Btw. in point 2, I think it would be easier to use `Pick` (or `Omit`) instead of writing the type of `args`, like so:
“`
export function declareContentType(
args: Pick<
ContentStackContentType,
“id” | “buildQuery” | “extractContent”
>
Another thing that I was wondering is whether the phantom properties are there only to be able to extract them via `ContentType` and `ArgsType` types (defined in point 3). If so, how about using:
“`
type ContentType = T extends ContentStackContentType ? C : never;
type ArgType = T extends ContentStackContentType ? A : never;
“`
Those let you extract the generic parameters easily without declaring phantom properties. I don’t see any cons to using this solution, and IMO it’s a bit safer, as TS matches runtime values :D
Example revised API in TS playground:
https://www.typescriptlang.org/play?#code/C4TwDgpgBAysCGBjA1lAvFeA7EBuAUKJFAKJbABOI6mOB+EAHmAPYXBRHQDCL5E5OEmS9+5ACrgIAHnGjgAjhmwgANFHEBBCgHMArgFtFNFQD4aAb3xQoASwAmALigBnSraw6CNgEZ7bADb2AIp6EFTOABRuws5CKOrwus5auoaKAJTo5mSUeNZQTJRIwPKKUYoRtCBZaOZyfArkBAC+9FxQZRJSsoWMTfYunY2K8SIj3ZDmGOJ9A0NdwGOLkpDSHgBm4Z3mAPydUM5YEABu4QQd2jqrMrNFAoPDYksIKCtS0xpzDwsTL8LvNYqdSbbaaPZQTSHKDHM4UegAegRUAAqi54DoIPh7BBEAEktBEHw3FAic9nItln8btI3BQPDp1D4WCwAhBsKZ2lInk0lDzFDSuCwNqS-pyoEi7EM6QzCNyrjQroKpMLRc9xZLbENmaz2Vh8EA
Hello!
Pick or Omit would be reasonable there, but the APIs of the declaration functions often diverge over time from the underlying type. As things get more complex the ideal declaration API is often higher-level than the underlying value type. The argument type design for the declaration function changes to improve user experience for declaring instances, whereas the underlying type changes to enable new capabilities. Since they types change for different reasons, I usually opt into the redundancy to help remind myself and my team about this distinction, but YMMV.
Regarding the phantom properties, you start to run into problems when you have type arguments that aren’t reflected in the structure of the type. Two types that have different type variables can be treated as equivalent, and things that need to infer the phantom type sometimes can’t because the unused type was erased. Basically: you get a combination of types that aren’t as strict as you’d like and broken inference. It just doesn’t work.
(This was at least an issues a few versions of TypeScript back, but I haven’t re-verified this in newer 4.x versions of TypeScript, as I haven’t felt the need. It could work differently now, but the type compatibility issue, at least, is pretty fundamental to structural typing.)
I also just prefer phantom properties to ternary types/inference for this use case. To be honest, I don’t always introduce utility types for extracting out type parameters, especially when the type is more of an internal detail that isn’t really needed outside of the core ecmascript module. You can always just do `Type[‘someProp’]` to use the types without necessarily introducing helper types or forcing yourself into pattern matching. Since I usually have the phantom properties for the above correctness/functionality reason, this benefit comes along for the ride.