Stack logo
Sync up on the latest from Convex.
Alex Cole's avatar
Alex Cole
2 years ago

End-to-end TypeScript with Convex

Convex provides automatic type safety from your database schema to your React app. How does it work? Hint: we use some wild TypeScript.

Convex provides automatic type safety all the way from your database schema to your React app. How does it work? Hint: we use some wild TypeScript.

A Convex app consists of:

  1. [Optional] A schema.ts file defining the tables in your project.
  2. Query and mutation functions that read and write documents in your tables.
  3. React components that invoke mutation functions and subscribe to query functions for realtime updates.

In Convex, all 3 of these layers are pure TypeScript. No query languages or configuration formats required.

Furthermore, every layer is completely type-safe given the previous one! The types in your query and mutation functions reflect your schema. The types of your React hooks reflect your query and mutation functions.

On top of that, most changes don’t require updating generated code. We use code generation to automatically create convenience utilities to build your app, but the types update automatically.

Under the hood, this uses a pattern I call “types as data structures”. Large TypeScript types pass around metadata about the schema and functions of the app.

Let’s start with some examples and then we’ll see how the magic works. You can also play with it yourself in a TypeScript playground.

The developer experience

Say we define the schema for a basic chat app like:

1// convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export default defineSchema({
6  messages: defineTable({
7    author: v.string(),
8    body: v.string(),
9  }),
10});
11

This defines a single table, messages, that contains documents with an author field and a body field.

Now if we write a query function to load messages; the db knows what tables we have and what types they store:

1// convex/messages.ts
2import { query } from "./_generated/server";
3
4export const list = query(async ({ db }) => {
5  const messages = await db.query("messages").take(10);
6  return messages;
7});
8

Writing a query functionWriting a query function

Furthermore, when we use this query in our React app, the React useQuery hook knows the query’s name and return type!

1import { useQuery } from "../convex/_generated/react";
2
3export default function App() {
4  const messages = useQuery(api.messages.list)
5}
6

useQuery knows the types of our functionsuseQuery knows the types of our functions

Lastly, let’s update our schema and add a channels table:

1// convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export default defineSchema({
6  messages: defineTable({
7    author: v.string(),
8    body: v.string(),
9    channel: v.id("channels")
10  }),
11  channels: defineTable({
12    name: v.string()
13  }),
14});
15

Immediately, the type in our React code updates with no code regeneration! You can see below that the messages now have a channel property.

The type of useQuery updates automaticallyThe type of useQuery updates automatically

That’s what end-to-end type safety is all about!

The rest of the post explains how Convex achieves this.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started

How is this possible?

This is built using a pattern I call “types as data structures”.

The idea is to construct TypeScript types to store information about the Convex app’s data model and API. These types are passed around as type parameters and accessed to make all of the methods type-safe.

For example, a type describing the data model of this chat app could look like this:

1type MyDataModel = {
2  messages: {
3    author: string;
4    body: string;
5    channel: Id<"channels">
6  };
7  channels: {
8    name: string;
9  }
10};
11

Here MyDataModel is an object type mapping table names to the type of object in each table. Documents in the "messages" table have author, body, and channel properties. Channels just have a name.

The strange thing about this type is that we never construct values that match it! There will be messages and channels, but never a MyDataModel object. It’s only used as a data structure to pass around type-level metadata about your schema that disappears completely at runtime.

We can pass around MyDataModel as a type parameter to make all of your interactions with Convex type-safe.

Let’s dig into how this data model type works. This is a simplified explanation of Convex’s TypeScript types, but it’ll give you a feel for how it’s built.

Using data structure types

Once we have a DataModel type, it’s possible to define a ton of useful type helpers. For example, we can use the keyof type operator to get a union of the string literal table names in the data model. An index access type extracts the document type.

1/**
2 * The table names in a data model.
3 * 
4 * This is a union of string literal types like `"messages" | "channels"`.
5 * 
6 * @typeParam DataModel - A data model type.
7 */
8type TableNames<DataModel> = keyof DataModel;
9
10/**
11 * The type of a document in a data model.
12 * 
13 * @typeParam DataModel - A data model type.
14 * @typeParam TableName - A string literal type of the table name (like "messages").
15 */
16type Doc<DataModel, TableName extends TableNames<DataModel>> = DataModel[TableName]
17

Now let’s build up the types used in our original example. Recall that we want to make this code block type safe:

1export default query(async ({ db }) => {
2  const messages = await db.query("messages").take(10);
3	return messages;
4});
5

We’ll do this from the inside outwards. First, we need a type for the .take(10) method within the database query:

1interface DatabaseQuery<Document> {
2  take(n: number): Promise<Document[]>;
3}
4

This is parameterized over some document type Document.

Then, we can use that to build the type of db. Note that this code is parameterized over a generic DataModel. It uses TableNames to extract the names of the tables from it and Doc to get the document type and pass it to DatabaseQuery.

1interface Database<DataModel> {
2  query<TableName extends TableNames<DataModel>>(
3    tableName: TableName
4  ): DatabaseQuery<Doc<DataModel, TableName>>;
5}
6

Lastly, we can define the type of the query wrapper itself.

query is a function that takes in an inner function and produces some RegisteredQuery. The inner function’s first argument is a context object with a db:

1type QueryBuilder<DataModel> = (
2  func: (ctx: { db: Database<DataModel> }, args?: any) => any
3) => RegisteredQuery;
4
5class RegisteredQuery {}
6
7declare const query: QueryBuilder<MyDataModel>
8

And that’s it! Now when you define a Convex query function with query(...), Convex will take your schema into account. If you want to see where this happens in real Convex apps, check out the generated code and our open-source npm package.

Building data structure types

Type parameters are cool, but how do we get a DataModel type?

We could code generate it, but then we’d need to regenerate the code every time the schema changes. Instead, we can infer the data model straight from the schema definition. That way, when you update your schema the TypeScript types will update automatically!

To recap, we want to take a schema definition like:

1const schema = defineSchema({
2  messages: defineTable({
3    author: v.string(),
4    body: v.string(),
5    channel: v.id("channels")
6  }),
7  channels: defineTable({
8    name: v.string()
9  }),
10});
11

and turn this into a type like:

1type MyDataModel = {
2  messages: {
3    author: string;
4    body: string;
5    channel: Id<"channels">
6  };
7  channels: {
8    name: string
9  }
10};
11

To do this we’ll need to:

  1. Define the type of the validator builder, v.
  2. Define the types of defineTable and defineSchema.
  3. Lastly, do the magic to convert the type of the schema into a data model type.

v

The validator builder v needs to keep track of the TypeScript type of each property in the schema. We can do this by constructing Validator<T> objects that store the underlying TypeScript type, T, in its type property. For example, v.string() returns a Validator<string>, v.id returns an Id for the table wrapped in a Validator, etc.

To get the underlying TypeScript type of a Validator, you can index into it:

1const validator = v.string();
2type ValidatedType = typeof validator["type"];
3

In Convex’s actual implementation, there’s additional information passed around with Validator, such as the field paths available for indexing.

We can build a basic version like this:

1class Id<TableName extends string> {
2  public tableName: TableName;
3  public id: string;
4
5  constructor(tableName: TableName, id: string) {
6    this.tableName = tableName;
7    this.id = id;
8  }
9}
10
11class Validator<T> {
12  type!: T;
13}
14
15declare const v: {
16  string(): Validator<string>;
17  id<TableName extends string>(tableName: TableName): Validator<Id<TableName>>;
18};
19

Okay, v.string() and v.id() were simple. For fun, let’s implement one of the tricky ones too: v.union.

v.union takes in a variable number of arguments, each of which is a Validator like v.union(v.string(), v.number()). We can represent this using a rest parameter like ...innerTypes: T where T is an array of Validators or a Validator<any>[].

It needs to return a Validator of a union of the underlying TypeScript types. How do we construct this?

T is an array type, so indexing into T like T[0] gives us the type of the first element. If we index instead with number like T[number], this gives us a union type of all the values in the array. That’s a union of Validators. Now if we index "type" into that, we get a union of all the underlying TypeScript types!

1declare const v: {
2  // ...
3  union<T extends Validator<any>[]>(
4    ...innerTypes: T
5  ): Validator<T[number]["type"]>;
6};
7

defineTable and defineSchema

Comparatively, defineTable and defineSchema are quite straightforward.

A TableDefinition is just an object mapping property names to their types. defineTable can just be an identity function of those. We’re using a type parameter here so that the original, more specific type of the table is preserved.

1type TableDefinition = Record<string, Validator<any>>;
2
3declare function defineTable<TableDef extends TableDefinition>(
4  tableDefinition: TableDef
5): TableDef;
6

Similarly, a SchemaDefinition is just an object mapping table names to their TableDefinitions. defineSchema just needs to take in and return SchemaDefinitions.

1type SchemaDefinition = Record<string, TableDefinition>;
2
3declare function defineSchema<SchemaDef extends SchemaDefinition>(
4  schema: SchemaDef
5): SchemaDef;
6

DataModelFromSchemaDefinition

Now that we have the type of a schema, we need to convert it into a data model.

First, we can convert a table definition, or Record<string, Validator<T>>, into the type of document in that table, or Record<string, T>. This uses a mapped type to iterate over the properties in the table definition and gets the TypeScript type of each one.

1type DocFromTableDefinition<TableDef extends TableDefinition> = {
2  [Property in keyof TableDef]: TableDef[Property]["type"];
3};
4

Then we need to do the same thing with SchemaDefinitions. For each table in the definition, we can call DocFromTableDefinition to get the document type:

1type DataModelFromSchemaDefinition<SchemaDef extends SchemaDefinition> = {
2  [TableName in keyof SchemaDef]: DocFromTableDefinition<SchemaDef[TableName]>;
3};
4

Then we can produce MyDataModel from our schema definition:

1type MyDataModel = DataModelFromSchemaDefinition<typeof schema>; 
2

Unfortunately, TypeScript refuses to simplify the result giving us an ugly type in our editor. Gross!

Ugly data modelUgly data model

Making it pretty

To solve this, let’s introduce the Expand type. Expand is functionally an identity type. It takes in some object type and maps all the original keys to the original values. It wraps this in a conditional type which helps to convince the TypeScript compiler to simplify the original type.

1type Expand<ObjectType extends Record<any, any>> =
2  ObjectType extends Record<any, any>
3    ? {
4        [Key in keyof ObjectType]: ObjectType[Key];
5      }
6    : never;
7

Now, if we add Expand into DataModelFromSchemaDefinition, we see that we’ve correctly constructed our data model type!

1type DataModelFromSchemaDefinition<SchemaDef extends SchemaDefinition> = {
2  [TableName in keyof SchemaDef]: Expand<
3    DocFromTableDefinition<SchemaDef[TableName]>
4  >;
5};
6

Pretty data modelPretty data model

Putting it all together

Now we have all the pieces to build our end-to-end type safety. To recap, we now can:

  1. Define a database schema in TypeScript.
  2. Turn that schema into a type describing the app’s data model.
  3. Use the data model type to write type-safe query functions.

To see this in action, check out this TypeScript playground with all of our code!

Convex also uses this same “types as data structures” pattern to make our React hooks like useQuery type safe by constructing an API type from all of the Convex functions.

Parting thoughts

If you thought this was interesting, check out Convex!

If you want to learn how the real Convex types work, check out our open-source code! validator.ts, data_model.ts, registration.ts, and api.ts are particularly interesting.

Thanks to my ex-coworkers at Asana, the maintainers of Zod and tRPC, and all of the other TypeScript enthusiasts I've met online and offline for teaching me about all the wild things TypeScript can do.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started