DEV Community

Adam Cowley
Adam Cowley

Posted on

Adding Record & Type Checking in TypeScript with Generics

Recently, while working on a new feature for Neo4j GraphAcademy, I noticed an omission with the Neo4j 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:

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

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 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:

async function getApiResponse<T>(uri: string):  Promise<T> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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

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

But also, use the function to fetch Movie details:

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

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 Nodes, Relationships.

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.

export interface PersonProperties {
    name: string;
    born: number;
}
Enter fullscreen mode Exit fullscreen mode

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.

class Node<Properties = Record<string, any>> {  // <1>
  public properties: Record<keyof Properties, Properties[keyof Properties];
  /* ... */ 
}
Enter fullscreen mode Exit fullscreen mode

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:

  public properties: Record<      // <1>
    keyof Properties,             // <2>
    Properties[keyof Properties]  // <3>
  >
Enter fullscreen mode Exit fullscreen mode
  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.

type Person = Node<PersonProperties>
Enter fullscreen mode Exit fullscreen mode

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

person.properties.unknown // <-- Property 'unknown' does not exist on type 'Record<keyof PersonProperties, string | number>'.
Enter fullscreen mode Exit fullscreen mode

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():

declare function getApiResponse<ResponseType>(uri: string): Promise<ResponseType>
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

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

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).

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

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

get<Key extends keyof RecordShape>(key: Key): RecordShape[Key] {
  return this.record.get(key)
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

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

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

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

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

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

If you have any comments or questions, feel free to reach out to me on Twitter.

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

Latest comments (0)