Adding Record & Type Checking in TypeScript with Generics
Tags: typescript, webdev, node, vscode
Published: 2022-10-20 11:02:43

Recently, while working on a new feature for [Neo4j GraphAcademy](https://graphacademy.neo4j.com), I noticed an omission with the [Neo4j](https://neo4j.com) JavaScript driver in TypeScript projects.

When running a query against the database, although I could define the values returned from the database, it was not possible for TypeScript to check the code for potential errors.

Say I ran the following Cypher statement to get a list of actors in a movie:

```ts
const res = await session.executeRead((tx: Transaction) => tx.run(`
  MATCH (p:Person)-[r:ACTED_IN]->(m:Movie {title: $title})
  RETURN p, r, m
  LIMIT 10
`, { title: 'Goodfellas' }))

const people = res.records.map(record => record.get('p'))
```



Now I know that the `people` object in the code above would be an array of `Node` objects.  But there's no way that TypeScript would be able to verify that.  

If I made a mistake in the code and tried to get a value that didn't exist from each record (for example `row.get('somethingElse')`), an Error would be thrown at _runtime_, but I wouldn't be able to catch this while developing - potentially causing hours of debugging pain.

Ideally, this is something that could be caught during the TypeScript evaluation of my code.  I wondered how I could add this type of checking to the driver.  

A few hours and a coffee later, I had written the code to .

Here's how I did it:

## Generics 

For anyone who has spent time writing Java, [Generics](https://www.typescriptlang.org/docs/handbook/2/generics.html) should be a familiar concept.  Generics allow you to provide _placeholders_ for types that may not be know up front.

You may have already used a Generic in TypeScript without noticing it - `Record<K, V>` is a generic which allows you to define the type of keys and values on a JavaScript object, or `Map<string, number>` would state that the keys in a `Map` would be strings, and the corresponding values should be a number.

To give another example, in the following code, the `T` generic allows you to define the data returned by a wrapper function calling an external API:

```ts
async function getApiResponse<T>(uri: string):  Promise<T> { /* ... */ }
```

So I can use this code to fetch a list of Users from an API:

```ts
interface User {
  id: number;
  name: string;
}
const users = await getApiResponse<User[]>('/api/users')
```

But also, use the function to fetch `Movie` details:

```ts
interface Movie {
  id: number;
  title: string;
  released: Date;
}
const movie = await getApiResponse<Movie>(`/api/movies/${id}`)
```

This is nice, because you don't need to know the type up front when implementing the function, but pass the responsibility on to the developer when they use it.


### Generics and Databases

Most database libraries will make use of generics when dealing with values returned from the database.  Neo4j is no exception, our driver works by sending a query and receiving results over a protocol called Bolt.  When the Driver receives a result back, it will hydrate the records into classes - in Neo4j a result can consist of individual property values, or Neo4j `Node`s, `Relationship`s.

In the case of rows in a table, or properties on a `Node` or `Relationship`, the values contained can be dynamic.  

In JavaScript terms, the properties of a node or relationship are an object (`{}`), in TypeScript terms a `Record<string, any>`, so we can use an `interface` to define the properties.

Each `(:Person)` node will have two properties; `name` which is a string and `born` which is a number.

```ts
export interface PersonProperties {
    name: string;
    born: number;
}
```

As long as the `Node` class accepts a generic to represent the Properties, TypeScript will be able to inspect the code and ensure that the properties we try to fetch are correct.

```ts
class Node<Properties = Record<string, any>> {  // <1>
  public properties: Record<keyof Properties, Properties[keyof Properties];
  /* ... */ 
}
```

The first line in the block above defines a new _generic_ called `Properties` which should default to `Record<string, any>` if none is applied.

If we break down this line:

```ts
  public properties: Record<      // <1>
    keyof Properties,             // <2>
    Properties[keyof Properties]  // <3>
  >
```

1. `properties` is a public property, which should always be an object which conforms to the `Record` generic
2. The key of each object should be a key from the `Properties` generic (`keyof Properties`)
3. The value of that key will be defined in the Properties (`Properties[title] === string`)


Then we can define a `Person` type to be a `Node` where the properties object matches the `PersonProperties` interface we defined above.

```ts
type Person = Node<PersonProperties>
```

If I now try to access a property that isn't defined in the interface, TypeScript will pick it up immediately.

```ts
person.properties.unknown // <-- Property 'unknown' does not exist on type 'Record<keyof PersonProperties, string | number>'.
```

So now that we can define the properties of a Node or Relationship, all that is left is to define the shape of the returned record itself.  How do we do that?  Generics!

## Generics and Function return types

As I hinted at in the `getApiResponse()` example above, you can define the type returned by a function by defining the . Let's take a look at the function definition for `getApiResponse()`:

```ts
declare function getApiResponse<ResponseType>(uri: string): Promise<ResponseType>
```

`ResponseType` is defined as a generic on the function, and will describe the value that the Promise will resolve to.

In the very first code block, I used a `session.run()` method to run a Cypher statement.  We can add a generic called `ResultShape` to the function to define the response that is returned.


```ts
class Session {
  async run<ResultShape extends Record<string, any>>(
    query: string, 
    params?: Record<string, any>
  ): Promise<Result<ResultShape>> {
    /* ... */
  }
}
```

The Promise resolves to a `Result`, a type supplied to driver which wraps a Neo4j result and provides a methods for handling results.  We can pass that `RecordShape` through to the `Result` class - which has a public `records` array representing each record.


```ts
class Result<RecordShape extends Record<string, any>> {
  constructor(
    public records: Neo4jRecord<RecordShape>[] = []
  ) {}
  /* ... */
}
```

The type of each item in `records` should correspond to a `Neo4jRecord` - a wrapper class for the raw result and a `get()` method for accessing an individual value from the record (eg, `p` for the Person node).


```ts
class Neo4jRecord<RecordShape extends Record<string, any>> {
  constructor(
    public readonly record: Record<keyof RecordShape, RecordShape[keyof RecordShape]>
  ) { }
  /* ... */
}
```

Now, we can use that `RecordShape` to check the `key` parameter passed to the `get()` function:

```ts
get<Key extends keyof RecordShape>(key: Key): RecordShape[Key] {
  return this.record.get(key)
}
```

If `key` is not a key of the RecordShape, the Typescript evaluator will throw an error.  Perfect!

The return type (`RecordShape[Key]`) will also inspect the RecordShape and interpret the type of the returned value, meaning that if an incorrect type is defined in the `map` function, the error will be picked up straight away:

```ts
res.records.map<Node<PersonProperties>>(row => row.get('m'))
// Type 'Neo4jNode<MovieProperties, any>' is not assignable
// to type 'Neo4jNode<PersonProperties, any>'.
//   Types of property 'properties' are incompatible.
```

The `m` value is a Movie and not a Person!


## Bringing it together

So now, I can define types for my database objects that the database will return:

```ts
/**
 * Node & Relationship Properties
 */
export interface PersonProperties {
    name: string;
    born: number;
}

export interface MovieProperties {
    title: string;
    released: number;
}

export interface ActedInProperties {
    actedIn: string[];
}

/**
 * My Query
 */
export interface PersonActedInMovie {
    person: Neo4jNode<PersonProperties>;
    actedIn: Neo4jRelationship<ActedInProperties>;
    movie: Neo4jNode<MovieProperties>;
}
```

Then for my Query (note the `person`, `actedIn` and `movie` items in my `RETURN` statement):

```ts
const query = `
  MATCH (person:Person)-[actedIn:ACTED_IN]->(movie:Movie)
  RETURN person, actedIn, movie LIMIT 10
`
```

I can code with peace of mind that as long as my Cypher query is correct, my application will work as expected:

```ts
// Run a query and return an array of PersonActedInMovie records
const res = await session.run<PersonActedInMovie>(query)

// Extract Movies
const movies = res.records.map(row => row.get('movie')) // <-- Fine, movie is in result

// Type checking on values
const people = res.records.map<Neo4jNode<PersonProperties>>(row => row.get('movie')) // <-- A Person is not a Movie

// Type checking on Node and Relationship properties
movies.map<string>(movie => movie.properties.title)   // <-- Fine, title is a string
movies.map<string>(movie => movie.properties.born)    // <-- Type 'number' is not assignable to type 'string'.
movies.map<string>(movie => movie.properties.unknown) // <-- Property 'unknown' does not exist on type 
```

## Wrapping things up

This post was intended to outline a real-world application of Typescript Generics and detail when and why you might use them.

You can [see the example code in full on the TypeScript Playground](https://www.typescriptlang.org/play?#code/C4TwDgpgBAkgdsCBzCAnKBeKBDOIBQ+A9AFQn5QlQDKA1hADYTAD2cUAwg9gM48Q8K5SkXwBjbnygA5CCwAsAKwBKEMS1QATADyr1W6gAtskKBAAeiOJp5Q9GnT2CoAlnCQAaHHgB8PqADeFFAh6nBOqACuYqyoABTBIUlQYJEARgwuYlCoENiabAwgOWoOAFx2pVra9CAsAGaV+ppGJhBe9gbGkADatQ1NDq2QALo+iVAAlIEAvoTJtTxxkxVx-Y2dLd0Qkz0jgRPJucCRqOwA8mmKasAAdItxwIYuPLe5zZMTcxMowNoA0hBihYrDYoOtBl02j41kCKoCQCtIVs2j0EfsgskjsxTuwni83lVNLdfrDEV98N8JLxbKoeJEGH9NsNoCCINZaUTtBE3J5vCA-AdkmEItFYnFUhksiVmjwKrIFCoucztj49pgoHtpgFvslflAmO4nssKnBIgBbNJoIVYkLHXFQfGvd4OV6GpBPCkTc0mXQ4s4AFXAEBhADcXi5YqtUCwAO7yuRKTZ+5osnxeNyaCymi1W1BebCoVDYEAJxXJlXQrWYfyqE6B4Paw5Je1nR3PZ1E14+sBxOIurRlpPKolpjPWbNQM2WtAFoslodK5opoaq6sYfzhniRjT9onjrPmOfFxGfZJzKmSWwAEVcoetmKS-D4LjYJpoAm3bBtttb7DgECxh+L5vmeSQXoQ1JSNQn6vuwj4hEQRBQAACjSlT0oyLKOiwGEMsAEy8CAcDZFEcB+phwDYWyHLItyzi8gWvgwgAjpEaCllAPLuF4YCFtg5o8AA-BUybcXyuACkiKExuaLwQBR+GKVhqr+AhWJ-lOgF4YyylUaqcRahScy2skxDITAWYIJGIBBqYLxTnGXGlNYUBuFAKAAa42QALSOcAUCRPwmg4LYDRWEJnAsAyIWRlAVpQOIV4yImijSCwWbaDJLCQKgwAuAIXiWey+WgHZ0BYPAiAoKgakTCKzhiruzYhJKmSkXkBRwEUblWaVnHFdZZXBh4LUpOk7UlPkhTFNwVoMHKXEMe4eyjaZ41Sh103dcUYAxrl+UCKJXIQtlB0FTwXhnWgh08H0QIDNdeUXWMa2mW10q5NtPWMBA5olZZFTiRM2q6kkpK1BUp37TdF3STDz0CPddSNE9t0YmNmlOrce05bDSPgkCUAjMZkHJQqw7cPlbA8M8YBZQjt1FX1NnlRqVXIGgdXCjTjUxM160fVtXU9S4LOgBUg39eVb22kLU0i8UoCQEDy2eGN8tfYrKSMxdx3LtDeOI5dqG60jhvnQIr0axNn2dTNZhMP9CCA0trjuCDszzODzBklDD2o2bPDw0bt3I49QcY+tWMdjjQc9ITxQk+elJjSEaeJcQZBCDIGXQAAZJUVNwbTLhgKbocXUIogWGAGgBW4iCoPU2BiNAKFoDwbBoxdP5TgJECq+7SAANwTGkGhwDmM6oGP3y1-XbkIGgLdt1AACyLDhhAPcCH3pVMEPvJj9iTC8BAmjT3mc+EAveVL03q-QAAgjEF-wLvtjqa3iCaPAR8rRGDfLOwgqDr2KAARXYqgAgIh8B3wbsvZurd26dzYK-X+8BN7bz7rlLuU8UqKnSplDuqB8Gfx8CfJIP934EIpkqYuNM6baAwbQihVCQjmi3gVRcxCFLYIKuw1O4heZQDYhxDUAADCY69n4Bg4AACSgBKNBU9SH4MmD5HoNC-5T2fhwAMABRa8AB9GA0gRg+RhFw7eZQBE7AmMoQxAYACqyhpCUCgAAGRgOvGAAYoAAEYAAM+BpHrXThnfARESJQHqJEEi1N2A+jcMsPuDUoCaDvNaLAAEgK3hcPeeIYFQiiOfF+dgWAsmFLQLccpcFlje1KeEAKuRbBYGwLGbAcV6lsDeAkrKqjWG6PsaxaB5IJhtMJLKW4PZ+xOQ3DkOMJJfYAHJzCrMmNMJCUBn6oCQBaEqUABjK2gKsgAROYc5qy3K2DgCwAKNIXBIDgNgDI0BWApH4v9JuxzGinKgKs06Qy34jO4RAVZtxgBLAAEwAGZ5AAFYSmmQmPVURNje5YCmQOGwsyTDzKAosmMsYVnADiKszFEKtlQB2doHyfkABibh2hQCpbcpeJRKJNLZeC7svpxLWPBTWXl2846VwEFCyMTBpiIWQvSplLKvAH2gA5bAbteTej5fi+mgq4jssWVS8VltXgTzOLKuVUAFVQDZqs6ceYbkOXuY8l8Ly3lMBwo6YMgLxKQq1dvfluq1ZCpwYa8Fxr8avASbQe5sY4DbPlQyiuB1iirOjbGuANyCh72dWYcwLwArfgBanIAA)


If you have any comments or questions, feel free to [reach out to me on Twitter](https://twitter.com/adamcowley).


Also: If you are a Neo4j user, **don't use `session.run()` in production**.  If you want to know why, or just want to learn more about how to use the Neo4j JavaScript Driver in a Node.js or Typescript project, check out [Building Neo4j Applications with Node.js](https://github.com/neo4j-graphacademy/app-nodejs)