Adding Record & Type Checking in TypeScript with Generics
Adam Cowley
Posted on October 20, 2022
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'))
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> { /* ... */ }
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')
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}`)
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.
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.
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:
public properties: Record< // <1>
keyof Properties, // <2>
Properties[keyof Properties] // <3>
>
-
properties
is a public property, which should always be an object which conforms to theRecord
generic - The key of each object should be a key from the
Properties
generic (keyof Properties
) - 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>
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>'.
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>
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>> {
/* ... */
}
}
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>[] = []
) {}
/* ... */
}
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]>
) { }
/* ... */
}
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)
}
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.
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>;
}
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
`
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
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
Posted on October 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.