Feeling Like I'm at Hogwarts

May 20, 2018 min read

I spent some time this week playing with Typescript 2.8’s new features for modeling various complex types. The new syntax that I wanted to play with was the conditional type syntax,

T extends U ? X : Y

This syntax allows you to express some really crazy type relationships! Some of the most interesting ones have been pre-defined in the Typescript standard lib:

  • Exclude<T, U> – Exclude from T those types that are assignable to U.
  • Extract<T, U> – Extract from T those types that are assignable to U.

I wanted to see if I could use these to model the way we’ve been working with return values from Contentful’s Content Delivery API.

Skip to the final type definitions –>

Modeling a CDN API response

We can break up the raw response into several re-usable sections. First, all links have a common structure that we can model with an ILink type:

{
  "sys": {
    "type": "Link",
    "linkType": "Entry",
    "id": "0SUbYs2vZlXjVR6bH6o83O"
  }
}
export interface ILink<Type extends string> {
  sys: {
    type: 'Link',
    linkType: Type,
    id: string,
  },
}

Notice that the ILink is generic over the LinkType. This lets us have a ILink<'Entry'> and an ILink<'Space'>, which cannot be assigned to eachother. This reflects all the kinds of links that can exist in a response.

Next let’s wrap up the entry Sys field in its own interface:

export interface ISys<Type extends string> {
  space: ILink<'Space'>,
  id: string,
  type: Type,
  createdAt?: string,
  updatedAt?: string,
  revision?: number,
  environment?: ILink<'Environment'>,
  contentType: ILink<'ContentType'>,
  locale?: string,
}

ISys is also generic over the type, so an ISys<'Entry'> and ISys<'Asset'> are not compatible.

Now we can define an Entry:

export interface IEntry<TFields> {
  sys: ISys<'Entry'>,
  fields: TFields
}

and an Asset:

export interface IAsset {
  sys: ISys<'Asset'>,
  fields: {
    // TODO: more fields for different asset types
    title?: string,
    description?: string,
    file: {
      url?: string,
      details?: {
        size?: number,
      },
      fileName?: string,
      contentType?: string,
    },
  }
}

This construction allows us to define Typescript interfaces for our content types by simply defining their fields, without duplicating the Sys object definition:

export interface IPageProps {
  title: string
  slug: string
  parent: ILink<'Entry'>
  sections?: Array<ILink<'Entry'>>
  subpages?: Array<ILink<'Entry'>>
}

export interface IPage extends IEntry<IPageProps> {}

export function isPage(entry: IEntry<any>): entry is IPage {
  return entry &&
    entry.sys &&
    entry.sys.contentType &&
    entry.sys.contentType.sys &&
    entry.sys.contentType.sys.id == 'page'
}

I love having intellisense pop up to help me see the fields on an entry! Intellisense on an entry

Now I have a problem though, because I want to take a link to a section and then download the actual section object in order to render it. One way to do that is to wrap a function around the API that resolves my links:

export function resolve<TLink>(link: ILink<TLink>): IEntry<any> {
  // hit the API here
}

But ideally, I’d like to be able to just resolve my tree of objects once using something like the include parameter and then replace links in my object structure with the actual resolved entry. The implementation of this is not too difficult - and less interesting. What’s more interesting is modeling the types. My goal is to be able to do something ridiculous like this:

const thumbUrl =
  page.fields.parent.fields.sections[0].fields.header.fields.thumbnail.fields.file.url

Defining the types to be able to do this in an elegant way is going to take some magic. So let’s take a trip to Hogwarts :)

First, let’s reflect the fact that my Page can have links that are either resolved or not, and define the allowable types that they can resolve to:

export interface IPageProps {
  title: string
  slug: string
  parent: ILink<'Entry'> | IPage
  sections?: Array<ILink<'Entry'> | PageSection>
  subpages?: Array<ILink<'Entry'> | IPage>
}

// this is all the content types that can be assigned to the Sections array.
export type PageSection = ISectionHeader | ISectionBlockText | ...

This gets me part-way there. Now instead of calling resolve to hit the API every time I want to access a section, I can just cast it. Or, to get some runtime protection, I can create a method expectResolved to early-exit if it’s still a link:

function expectResolved<T>(prop: T): Exclude<T, ILink<any>> {
  if (isLink(prop)) {
    throw new Error(`Expect ${prop.sys.id} to have been resolved but was still a link.`)
  }
  return prop
}

usage:

  public render() {
    const { title, sections } = this.props.page.fields

    return <div>
      <h1>{title}</h1>
      {sections.map((s) => this.renderSection(expectResolved(s)))}
    </div>
  }

  private renderSection(s: PageSection) {

But this doesn’t satisfy me. This is some Ron Weasley code. What would Hermione do?

Enter the magic

I know I can recursively resolve my entire tree at runtime, but can I recursively modify the type definitions in order to tell Typescript that ILink is never going to be present in these props? I certainly don’t want to maintain two versions of IPage. That would be a pain. I’d ideally like a generic type Resolved<T> such that Resolved<IPage> says there are no links in the fields, and no links in the fields of those linked fields. This’ll enable my desired result:

const page: Resolved<IPage> = ...
const thumbUrl =
  page.fields.parent.fields.sections[0].fields.header.fields.thumbnail.fields.file.url

Fortunately Typescript 2.8 just introduced a new form of magic - the conditional type defs! Using those together with the infer keyword, I can basically use if statements and assign variables inside a typedef!

So let’s go top-down. Supposing I have an IEntry<ISomeFields>, I want to transform ISomeFields such that ILink<any> does not appear in any of its properties.

export type Resolved<TEntry> =
  TEntry extends IEntry<infer TProps> ?
    // TEntry is an entry and we know the type of it's props
    IEntry<{
      [P in keyof TProps]: ... // Transform the values of these props
    }>
    // Compiler should validate that TEntry is never not an entry
    : never

Now we’re down into the weeds of this spell. We have access to the type of each property in the entry’s fields via TProps[P]. If the field name is a string, then TProps['name'] == string. If the field parent is an ILink<'Entry'> | IPage, how do we get it to be just an IPage? Exclude does the trick:

  [P in keyof TProps]: Exclude<TProps[P], ILink<any>>

Alrighty, now we’ve got parent working. We also get assets for free, since ILink<'Asset'> | IAsset becomes just IAsset. Time to go deeper. We need to handle Arrays. Let’s define a new type:

type ResolvedArray<TItem> = Array<Exclude<TItem, ILink<any>>>

And a wrapper type that checks if the field is an array:

type ResolvedField<TField> =
    TField extends Array<infer TItem> ?
      // Array of entries - dive into the item type to remove links
      ResolvedArray<TItem> :
      TField

Then we use that in our properties declaration:

  [P in keyof TProps]: ResolvedField<Exclude<TProps[P], ILink<any>>>

Now we’ve got a type that wraps any IEntry, conforming to the IEntry interface and none of it’s properties are links! So we can do this:

const page: Resolved<IPage> = ...
const headerSection = page.fields.parent.fields.sections[0]
// headerSection is `ILink<'Entry'> | PageSection`

but we can’t recursively dig deeper. Yet :)

Going recursive

In Harry Potter, Dumbledore gives Hermione a Time-Turner, which can rewind time allowing her to get to all her classes and finish her homework every night.

Hermione’s time turner

That’s the kind of power that recursion gives us! Thanks to Typescript 2.8, we can go recursive in type definitions as well, building a type definition that recursively modifies properties all the way down the tree! All we have to do is find every spot in our typedef that could be an entry, and if it is, wrap it in a Resolved<>.

type ResolvedField<TField> =
  // three cases - an Entry which we recursively declare resolved,
  //  an Array which we recursively declare resolved,
  //  or a normal field which we declare to not have links.
  TField extends IEntry<infer TProps> ?
    // Single entry link, recursively declare it resolved.
    Resolved<TField> :
    TField extends Array<infer TItem> ?
      // Array of entries - dive into the item type to remove links
      ResolvedArray<TItem> :
      // Some other type that doesn't need recursive resolution -
      //  declare it has no links and be done
      TField

type ResolvedArray<TItem> =
  TItem extends IEntry<any> ?
      // Entries must be recursively resolved.
    Array<Resolved<TItem>> :
    Array<Exclude<TItem, ILink<any>>>

export type Resolved<TEntry> =
  TEntry extends IEntry<infer TProps> ?
    // TEntry is an entry and we know the type of it's props
    IEntry<{
      [P in keyof TProps]: ResolvedField<Exclude<TProps[P], ILink<any>>>
    }>
    // Compiler should validate that TEntry is never not an entry
    : never

I found it! I found the incantation which lets me magically declare all fields as resolved all the way down the tree!

Application

This has real-life uses for our new app at Watermark Community Church. We have a tree of available downloads wrapped in nested categories, which we display using react components on a single page. We pull this tree from Contentful, recursively grabbing all the objects in the tree and building out the tree structure in a way that looks like the above type definitions. Then we write that JSON out to the page, where our React components pick it up.

The top-level react component accepts a Resolved<IResourceTree> in the props, and thus it no longer has to worry about whether a node on the tree contains the actual values, or needs to be resolved. That’s asserted by Typescript - you can’t assign an IResourceTree directly to the properties, you have to wrap it in expectResolved() which performs the runtime validation.

I’m really enjoying the expressive power of the typescript type system, and it’s ability to catch errors you didn’t even think to write tests for!