Type checking and object mapping

Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters.

The examples in this page assume mapping symbols are imported into your application:

import neo4j, {Rules, rule, MappedQueryResult, Node, RecordObjectMapping} from 'neo4j-driver'

Read from the database

To map records into objects of a specific type, define a class having the same attributes as the keys returned by the query. The class attributes must match exactly the query return keys (case included).

Queries returning properties

Class and mapping definition
class Movie { (1)
  title: string
  release?: number
  constructor(title: string, release: number) {
    this.title = title
    this.release = release
  }
};

const movieRules: Rules = { (2)
  title: rule.asString({}),
  release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) (3)
};
1 Each record from the query result must conform to the Movie class definition.
2 Rules is a map-like object holding the typed mappings between the JavaScript class definitions and the database schema. Each property is specified via a rule call.
3 Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, release is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property released even if the class attribute is called differently. For more information on allowed parameters, see API docs → rule.
Query execution
let res = await driver.executeQuery<MappedQueryResult<Movie>>(` (1)
  MERGE (m:Movie {title: "Cloud atlas", released: 2013})
  RETURN m.title AS title, m.released AS released
  `, {},
  {resultTransformer: neo4j.resultTransformers.hydrated(Movie, movieRules)} (2)
)

console.log(res.records[0])
// Movie { title: 'Cloud atlas', release: 2013 }
1 The return type MappedQueryResult<Movie> is optional. If given, it must match the type provided in the .hydrated() call later.
2 The .hydrated() call maps each return record to a Movie object according to movieRules.

Queries returning nodes or relationships

For queries returning graph entities (nodes/relationships), you need two classes: one with the inner properties of the node (ex. Movie), and one wrapping the node object itself (ex. movieNode), which references the first class.

Class definitions
class Movie { (1)
  title: string
  release?: number
  constructor(title: string, release: number) {
    this.title = title
    this.release = release
  }
};
class movieNode { (1)
  movie: Movie
  constructor(movie: Movie) {
    this.movie = movie
  }
};
1 Each record from the query result must conform to the movieNode class definition, and the contents of nodes must conform to the Movie class. The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don’t rely on constructors to manipulate attributes.
Mapping definitions
const movieRules: Rules = { (1)
  title: rule.asString({}),
  release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) (2)
};
const movieNodeRules: Rules = { (1)
  movie: rule.asNode({
    convert: (node:  Node) => node.as(Movie, movieRules)
  }),
};
1 movieRules and movieNodeRules are map-like objects holding the typed mappings between the JavaScript class definitions and the database schema. Properties are specified via rule calls.
2 Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, release is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property released even if the class attribute is called differently. For more information on allowed parameters, see API docs → rule.
Query execution
let res = await driver.executeQuery<MappedQueryResult<movieNode>>( (1)
  `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`,
  {},
  {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} (2)
)

console.log(res.records[0])
// movieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } }
1 The return type MappedQueryResult<movieNode> is optional. If given, it must match the type provided in the .hydrated() call later.
2 The .hydrated() call maps each return record to a movieNode object according to movieNodeRules.

Queries returning objects

With rule.asObject() you can create mappings for results returning subsets of properties. Excluding some properties from return can save bandwidth, especially if the nodes contain large properties (such as vectors) which are not relevant for the mapping.

Class definitions
class Person {
  name: string
  born?: number
  constructor(name: string, born: number) {
    this.name = name
    this.born = born
  }
};
class Movie {
  title: string
  release?: number
  constructor(title: string, release: number) {
    this.title = title
    this.release = release
  }
};
class Roles {
  roleNames: string[]
  constructor(roleNames: string[]) {
    this.roleNames = roleNames
  }
};
class ActingJob {
  person: Person
  movie: Movie
  roles: Roles
  constructor(person: Person, movie: Movie, roles: Roles) {
      this.person = person
      this.movie = movie
      this.roles = roles
  }
};

Properties for each objects are specified via rule calls.

Mapping definitions
const personRules: Rules = {
    name: rule.asString(),
    born: rule.asNumber({ isInteger: true, optional: true })
};
const movieRules: Rules = {
    title: rule.asString({}),
    release: rule.asNumber({ isInteger: true, optional: true, from: "released" }),
};
const rolesRules = {
    roleNames: rule.asList({apply: rule.asString(), from: "roles"})
};
const actingRules: Rules = {
  person: rule.asObject(Person, personRules),
  movie: rule.asObject(Movie, movieRules),
  roles: rule.asObject(Roles, rolesRules),
  costars: rule.asList({ apply: rule.asObject(Person, personRules) }) (1)
};
1 The .asList() rule applies the rule given in its apply parameter to every entry in the list. Here, each costars entry is made into a Person object.
Query execution
let res = await driver.executeQuery<MappedQueryResult<ActingJob>>(` (1)
  MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person)
  RETURN
    {name: p.name, born: p.born} AS person,
    {roles: r.roles} AS roles,
    {released: m.release, title: m.title} AS movie,
    COLLECT({name: c.name, born: c.born}) AS costars
  `, {},
  {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingRules)} (2)
)

console.log(res.records[0])
1 The return type MappedQueryResult<ActingJob> is optional. If given, it must match the type provided in the .hydrated() call later.
2 The .hydrated() call maps each return record to a ActingJob object according to actingRules.

Registering per-type default rules

The previous examples provided ActingJob and actingRules together, which requires the rules objects to be available in every part of the code where a query is run.

To avoid this, you can link the rules to the object, so that you don’t have to provide the rules anywhere else (except to override the default). This registry exists in global memory and is thus shared between driver instances.

Register rules to their objects
RecordObjectMapping.register(Person, personRules)
RecordObjectMapping.register(Movie, movieRules)
RecordObjectMapping.register(Roles, rolesRules)

The object rules and the .hydrate() call can then be stripped of their rules objects:

Simplified rules
const actingRules: Rules = {
  person: rule.asObject(Person),
  movie: rule.asObject(Movie),
  roles: rule.asObject(Roles),
  costars: rule.asList({ apply: rule.asObject(Person) })
};
Simplified query execution
let res = await driver.executeQuery<MappedQueryResult<ActingJob>>(`
  MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person)
  RETURN
    {name: p.name, born: p.born} AS person,
    {roles: r.roles} AS roles,
    {released: m.release, title: m.title} AS movie,
    COLLECT({name: c.name, born: c.born}) AS costars
  `, {},
  {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob)}
)

Mapping with different driver’s APIs

Usage with driver.executeQuery()

await driver.executeQuery<MappedQueryResult<movieNode>>(
    `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`,
    {},
    {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)}
)

Usage with transaction functions

await session.executeWrite(async (tx) => {
    return await tx.run(
      `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`
    ).as(movieNode, movieNodeRules) (1)
});
1 The Result is converted to a MappedResult<movieNode> and can be consumed, subscribed to, or awaited like a normal result. The difference is that Record objects are replaced with movieNode objects.

Usage with session.run()

await session.run(
    `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`
).as(movieNode, movieNodeRules) (1)
1 The Result is converted to a MappedResult<movieNode> and can be consumed, subscribed to, or awaited like a normal result. The difference is that Record objects are replaced with movieNode objects.

Write to the database

The mapping features allow you map objects into database entities when providing them as query parameters.

Client objects having properties of a native type (ex. strings) can be mapped directly; on the other hand, mapping rules can help sanitize or transform other types of data (ex. numbers, temporal types) before sending them to the server.

class Person {
  name: string
  created?: string

  constructor(name: string, created: string) {
    this.name = name
    this.created = created
  }
};

const rules = { (1)
  name: rule.asString(),
  created: rule.asDate({ stringify: true }), // stored as Date in the DB; mapped as string
}

RecordObjectMapping.register(Person, rules) (2)

const res = await driver.executeQuery<MappedQueryResult<Person>>(`
    MERGE (p:Person {name: $name, created: $created})
    RETURN p.name AS name, p.created AS created
    `, new Person("Bob the Builder", "1999-04-12"), (3)
    { resultTransformer: neo4j.resultTransformers.hydrated(Person) })
1 Rules and class definitions work similarly as for reading data.
2 The rules object must be registered into the mapping registry, associated with the class the rules refer to.
3 An object of type Person is sent as query parameter.

If a class you use for mapping has function properties, you need to exclude such properties from mapping, as the driver has no way of meaningfully serializing functions.

class Person {
  function1: Function
  constructor() {
    this.function1 = () => "test" (1)
  }
  function2() { (2)
    return "function string"
  }
};

const rules = {
  function: {
    parameterConversion: () => undefined, (3)
    optional: true (4)
  }
}
1 This function is a property, and the driver will attempt to map it.
2 This function is a method, and will not cause issues.
3 With an undefined parameterConversion, the function property is skipped when sending objects instances as parameters.
4 As the record mapping attempts to fill all the object’s properties when receiving results, the function property must be optional.

Mapping behavior

  • The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don’t rely on constructors to manipulate attributes.

  • You can create custom Rule objects performing more advanced type validation or conversions. The Rule type is exported by the driver.