DEV Community

loading...
Hasura

How TypeScript template literal types helped us with multiple database support

hasurahq_staff profile image Hasura Originally published at hasura.io on ・5 min read

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}`

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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}`
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

or

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

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

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} />
  );
}
Enter fullscreen mode Exit fullscreen mode

and

const supportedDrivers = getSupportedDrivers('actions.relationships');
Enter fullscreen mode Exit fullscreen mode
  • 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,
    },
  },
  ...
};
Enter fullscreen mode Exit fullscreen mode

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.

How TypeScript template literal types helped us with multiple database support

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>;
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

and

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

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

How TypeScript template literal types helped us with multiple database support

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.

Discussion (0)

Forem Open with the Forem app