/

hasura-header-illustration

How TypeScript template literal types helped us with multiple database support

Last year November's release of TypeScript was one of the most exciting releases. Literal types took over the TypeScript community by opening a whole new set of possibilities. The community created many amazing projects: router parameter parsing, JSON parser, GraphQL typed AST, SQL query validation, CSS parsing, games, a database implemented purely with TypeScript types, and many more. At Hasura, we recently had an interesting use case as well. But firstly — what are the literal template types?

A few words on template literal types

We already had Template literals in JavaScript that provide a convenient way to have values inside of strings. For example:

const variant = `button_${size}_${color}`

In TypeScript, the type of the above value would be a string, which is fair, but can we have more? Imagine that we know exactly those values because there are only two possible sizes and three possible colours. We could do this:

type Size = "small" | "big"
type Color = "green" | "red" | "white"

type Variant = 
  | "button_small_green" 
  | "button_small_red" 
  | "button_small_white" 
  | "button_big_green"
  | "button_big_red"
  | "button_big_white"

Better, but... maintaining the Variant type seems like a nightmare, doesn't it? Every time we extend either Color or Size, we need to add new literals to our Variant union. That's when TypeScript 4.1 comes to the rescue. We can use template syntax not only for values, but also for types! Thanks to template literal types we can simplify the above code:

type Size = "small" | "big"
type Color = "green" | "red" | "white"

type Variant = `button_${Size}_${Color}`

Isn’t it great? Now, let's see why we needed template types and what's our use case.

Multiple database support in the Hasura Console

In the Hasura 2.0 release, we introduced support for multiple databases. You can not only connect many databases to your Hasura, but also you can connect different databases! Postgres, BigQuery, MS SQL Server, and soon more. They now can all be managed via Hasura Console. However, each of them supports a different set of features. For example, some DDL functionalities are disabled for Big Query. MS SQL Server handles fetching data differently. A few features are not yet supported for a particular database. There are a lot of cases to handle!
We could do this:

if (currentDatabase === "postgres") {
   // render a feature
else {
  // don't
}

or

const supportedDatabases = ["bigquery", "postgres"]

const Component = ({ currentDatabase }) => {
  if (!supportedDatabases.includes(currentDatabase) {
     return null
  }
  // ...
}

But I think we can agree that it won't scale well. Especially if we need to handle those cases in dozens of components. And when we add a new database, we'd need to go over it again.


We needed some abstraction for that. Basically we needed something like this:

if (isFeatureSupported('tables.browse.enabled')) {
  return (
    <BrowseData {...props} />
  );
}

and

const supportedDrivers = getSupportedDrivers('actions.relationships');
  • One source of truth to declare enabled features for each database that can be extended in any way and multiple levels deep.
  • A function that, for a given feature, will tell us if it's supported based on the currently active database.
  • A function that, for a given feature, will tell us all the supported databases (needed when filtering out unsupported databases).


Our source of truth can be extended in any way. It can be multiple levels deep. It can be nested as well.
Here's an example of a database’s features configuration:

export const supportedFeatures = {
  driver: {
    name: 'mssql',
  },
  tables: {
    create: {
      enabled: false,
    },
    browse: {
      enabled: true,
      customPagination: true,
      aggregation: false,
    },
    ...
  },
  events: {
    triggers: {
      enabled: true,
      create: true,
      edit: false,
    },
  },
  ...
};

While the isFeatureSupported and getSupportedDrivers functions are fairly straightforward to implement, it's the developer experience that we needed to be enhanced. We didn’t want to type tables.browse.enabled by hand. We didn’t want to check the supported features object every time to see the shape. We wanted to have it autocompleted!
After all, they don't make those memes without reason.


Taking advantage of template literal types

We used template literal types to have a type-safe string dot notation. The following types Path and PathValue allowed us to have a function get that accepts an object and a path as string dot notation and returns a values under the path.

type Path<T, Key extends keyof T = keyof T> =
  (Key extends string
  ? T[Key] extends Record<string, unknown>
    ? | `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<unknown>>> & string}`
      | `${Key}.${Exclude<keyof T[Key], keyof Array<unknown>> & string}`
      | Key
    : never
  : never)

type PathValue<T, P extends Path<T>> =
  P extends `${infer Key}.${infer Rest}`
  ? Key extends keyof T
    ? Rest extends Path<T[Key]>
      ? PathValue<T[Key], Rest>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never;

declare function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P>;

I won’t go into the details of the above code. There are already plenty of amazing articles explaining how template types work. I link to a few of them at the bottom of this blogpost. I encourage you to check them out!

You can also play with the above code here:  https://tsplay.dev/mL9dbW

With the Path and PathValue types and get function we were able to implement our database utilities as:

export const isFeatureSupported = (feature: Path<typeof supportedFeatures>) => {
  return get(dataSource.supportedFeatures, feature);
};

and

export const getSupportedDatabases = (
  feature: Path<SupportedFeaturesType>
) => {
  return databasesConfig
    .filter(config => get(config, feature))    
    .map(d => d.driver.name);
};

As the result we we have: a) type-safe dot string notation, b) autocomplete 🎉


Summary

About a year ago, we decided to adopt TypeScript, which definitely empowered us to write code with confidence. It not only allows us to write safer code but also hugely improves our developer experience. Template literal types were a big help when implementing multiple databases support, and we're excited to explore more use cases for them!

Do you have any use cases for template literal types that you'd like to share? Let us know in a comment!

Further reading:

Learn more about TypeScript template literal types:

​​If you are interested in learning more about how we addressed some of the other technical complexities of multiple database support, check out the post Building a GraphQL to SQL compiler on Postgres, MS SQL, and MySQL.

Blog
11 May, 2021
Email
Subscribe to stay up-to-date on all things Hasura. One newsletter, once a month.
Loading...
v3-pattern
Accelerate development and data access with radically reduced complexity.