Visitar URL original
Pre-release changes to Object Mapping by MaxAake · Pull Request #1407 · neo4j/neo4j-javascript-driver · GitHub
Skip to content

Pre-release changes to Object Mapping - #1407

Merged
MaxAake merged 3 commits into
6.xfrom
pre-release-mapping-changes
Jun 4, 2026
Merged

MaxAake merged 3 commits into
6.xfrom
pre-release-mapping-changes

Conversation

@MaxAake

@MaxAake MaxAake commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes: DRIVERS-417

This is a PR to get the Object Mapping feature over the finish line ahead of 6.1 release, as such I will include an updated version of the usage guide which was used for some internal testing of the feature. This has all usage examples updated to work with a fresh movies example database.

Usage guide (quite long)

JavaScript Driver Record Object Mapping

Introduction

The following is a guide to the preview of Record Object Mapping in the javascript neo4j-driver. This is a feature introduced to aid users with typechecking and securely map the records of a query result to user defined types.

IMPORTANT NOTE: The classes being mapped must have a constructor function that can be called without arguments, and all properties that are mapped must be publicly accessible. Mapping values into a constructor may be a future feature.

To start the run-through, we´ll introduce the Rules object, which is a set of intructions for converting a Record to a given Type. It is a JSON object with keys matching the keys of the mapped type, each one containing a Rule object describing the way to map the object.

Below is an example of a not overly simple use case, showing the of types used in later examples in this document. This document is written to run against a fresh instance of the neo4j movies example database.

class ActingJob {
    person: Person
    movie: Movie
    roles: Roles
    costars: Person[]
    //During driver execution, this constructor will be called with all arguments undefined. 
    constructor (person: Person, movie: Movie, roles: Roles, costars: Person[]) { 
        this.person = person
        this.movie = movie
        this.roles = roles
        this.costars = costars
    }
}

class  Movie {
    title: string
    release?: number
    tagline?: string
    constructor(title: string, release: number, tagline: string) {
        this.title = title
        this.release = release
        this.tagline = tagline
    }
}

class Person {
    name: string
    born?: number
    constructor(name: string, born: number) {
        this.name = name
        this.born = born
    }
}

class Roles {
    roleNames: string[]
    constructor(roleNames: string[]) {
        this.roleNames = roleNames
    }
}

The ActingJob type is going to be what´s being mapped in this case. It holds the user-relevant data from the following query

MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
WHERE id(p) <> id(c) 
RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars

Mapping Rules

The defined types can be provided as is, and the driver will make a best effort to map them. But the above query is not actually returning the data in the format of our types, but as Nodes and Relationships. We would also not benefit from any type-mapping, as the javascript engine Runtime doesn´t know what type annotations have been put on the class properties.

Let´s go over some potential rules for the simple types we defined!

const personRules: Rules = {
    name: rule.asString(),
    born: rule.asNumber({ isInteger: true, optional: true })
}

This sets string typechecking for the name property and Number typechecking for born. In addition, the born property is set to be optional and be an Integer rather than a float.

const movieRules: Rules = {
    title: rule.asString({}),
    release: rule.asNumber({ isInteger: true, optional: true, from: "released" }),
    tagline: rule.asString({ optional: true })
}

const rolesRules = {
    roleNames: rule.asList({apply: rule.asString(), from: "roles"})
}

The movieRules are rather similar to those for Perso, but it introduces the extra rule config from. This simply changes the key the property is matched from, useful when there is an inconsistency in the name between code and data. (For differing namning conventions, see later in the document). Since actors have a list of roles for each movie they acted in, we have to set that the roles are a list, each entry of which is a string.

Now we put these rules together to create the rules to map the query result to an ActingJob object.

const actingJobRules: Rules = {
    person: rule.asNode({
        convert: (node: Node) => node.as(Person, personRules)
    }),
    roles: rule.asRelationship({
        convert: (rel: Relationship) => rel.as(Roles, rolesRules)
    }),
    movie: rule.asNode({ 
        convert: (node:  Node) => node.as(Movie, movieRules)
    }),
    costars: rule.asList({
        apply: rule.asNode({
            convert: (node: Node) => node.as(Person, personRules)
        })
    })
}

This introduces the functions asNode, asRelationship. These perform typechecking, then take optional configurations to convert into other types.

A record mapped according to these Rules is then known to obey these rules and function as an ActingJob object, and IDEs like VSCode will be able to provide reasonable hints and help.

Possible type mapping rules

Below is a full list of all types with pre-defined RulesFactory functions, as well as eventual optional parameters.
In addition to the listed optional parameters, all functions accept from : string and optional : bool.

It is also possible to define a custom Rule without use of a RulesFactory function. Rule is an interface with 4 properties, all optional.
from and optional have already been covered, the other two are the ones which are set by the RulesFactory functions convert and validate

convert is a (any, string) => any function, where the first parameter is the value recieved, and the second in the name of the field being mapped.
convert performs the conversion of the value as present in the raw record to the value as used in user code.

parameterConversion is a (any) => any function, where the parameter is the value recieved.
parameterConversion performs the conversion of the value in user code to a parameter

validate is a (any, string) => void function, with the same two input parameters as convert.
validate is designed to throw an error if the value recieved is not of the correct type, and do nothing otherwise.

Type RulesFactory funtion Additional Optional parameters
String asString()
Number asNumber() isInteger : bool
BigInt asBigInt acceptNumber : bool
Integer asInteger acceptNumber : bool
Node asNode()
Relationship asRelationship()
Path asPath()
Duration asDuration() stringify : bool
Time asTime() stringify : bool
Date asDate() stringify : bool, jsNativeDate: bool
DateTime asDateTime() stringify : bool, jsNativeDate: bool
LocalTime asLocalTime() stringify : bool
LocalDateTime asLocalDateTime() stringify : bool, jsNativeDate: bool
Point asPoint()
List asList() apply : function
Vector asVector() asTypedList: bool, dimension: number, type: string
Object asObject() constructorOrRules: GenericConstructor

isInteger makes a Number field map to transmit as Integer to the DB

acceptNumber makes a BigInt or Integer field also accept Numbers.

stringify makes automatically converts the value in a temporal type field to a string.

apply sets a function to convert values contained in a List. An example can be seen in the costars field in actingJobRules

The arguments for asObject() may look a little odd now, but it aligns with the way arguments are passed when using mapping rules in other places like Record.as() or neo4j.resultTransformers.hydrated(), which can be seen in the coming sections.

Using Mapping Rules

The mapping rules can be used to map Result objects, individual Records or be used to by a special ResultTransformer to map the results of a query.

Each of the mapping methods take the type to map to and/or a set of rules. If only the class is provided, then the driver will make a best effort, but cannot ensure type safety. If only a Rules object is supplied, then the Result or Record will be mapped, but the driver cannot provide the IDE with type hints. The recommended usage is to utilize both, later in this document there is a convenience function to make this simpler.

Usage with transaction functions

Transaction functions are a very simple usecase. As the functions make a Result object easily accessible.

const res = await session.executeRead(async (tx) => {
    return tx.run(
    `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
 WHERE id(p) <> id(c) 
 RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`).as(ActingJob, actingJobRules)  //The Result is here converted to a MappedResult<ActingJob> and can be consumed, subscribed to or awaited like a normal result, but all Records are replaced with ActingJobs.
});

res.records[0].person.born //after being awaited, res is a MappedQueryResult, containing an array of ActingJobs and a ResultSummary. You and your IDE can now feel safe in knowing that this is a Number.

Usage with driver.executeQuery()

To transform an executeQuery() Result, we need to provide one of our new resultTransformers.

const res = await driver.executeQuery<MappedQueryResult<ActingJob>>(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
 WHERE id(p) <> id(c) 
 RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`, {}, {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingJobRules)}) //Think it´s annoying to need both the class and the rules every time? Keep reading, we´ll get there!

Annoyingly, the type system is not quite clever enough to realize that the provided resultTransformer will ensure we get a MappedQueryResult<ActingJob> back, so we do need to set that in its Type Parameter.

Usage with session.run()

session.run() is kind enough to directly return a Result object, and is as such very handy to typemap.

const res2 = await session.run(
    `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
 WHERE id(p) <> id(c) 
 RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`
 ).as(ActingJob, actingJobRules)

Using only Classes or Rules

Every mapping function takes a class and/or a Rules object. Following will be a short explaination of functionality if using just one of the other

Only Class

When providing only the ActingJob class (unless you have used the register convenience function which will be explained in the next section), the driver will perform a best effort mapping.

If the contents of the record match the names of class properties precisely, and all the types are correct, this will work fine.

It is however incapable of unpacking a Node or Relationship object, so in the ActingJob example this would not work.

To make .as(ActingJob) work out of the box, the query would need to be rewritten as:

    `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
    WHERE id(p) <> id(c) 
    RETURN {name: p.name, born: p.born} AS person, { roles: r.roles } as roles, {released: m.released, title: m.title, tagline: m.tagline} AS movie, COLLECT({name: c.name, born: c.born}) AS costars`

and even with this new query, we would get no feedback if a required property is missing, or of an incorrect type.

Only Rules

Providing only the Rules object gives you every bit of type safety and special handling as the usual case, but any typescript checks and your IDEs hints will not be aware of all the hard work the driver has done. Depending on how you develop this may be acceptable, but the limitations should be known.

asObject() rule factory

The asObject rule factory allows nesting in the created rules.

It's useful when you're not just returning one object in your records, and allows you to map your result into multiple objects without returning full nodes or relationships to transform.

Much like other instances when you can pass rules, you can also pass a constructor function (must be callable with undefined arguments) to have the asObject conversion convert to that class.

With asObject we can create a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships.

const actingJobRulesObject: Rules = {
    person: rule.asObject(Person, personRules),
    role: rule.asObject(Role, roleRules),
    movie: rule.asObject(Movie, movieRules),
    costars: rule.asList({
        apply: rule.asObject(Person, personRules),
    }),
    costar: rule.asVector({type: "INT8"})
}

await session.run(
    `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
    WHERE id(p) <> id(c) 
    RETURN {name: p.name, born: p.born} AS person, {name: r.characterName} as role, {released: m.release, title: m.title, tagline: m.tagline} AS movie, COLLECT({name: c.name, born: c.born}) AS costars`
).as(ActingJob, actingJobRulesObject)

This setup saves on bandwitdth, especially if the nodes contain large properties like vectors which are not used for mapping.

Convenience functions

To further improve developer experience two sets of convencience functions exist .

Registering per-type default rules.

In the examples above, we have always provided both ActingJob and actingJobRules, which is a bit of a hassle, really. And also requires that we import these rules to be available in every part of the code where we run a query. Wouldn´t it be better if we could just tell the driver that ActingJob map according to actingJobRules unless otherwise specified?

We hope you said yes, because otherwise we´ve wasted a bit of time here!

recordObjectMapping.register(ActingJob, actingJobRules)

The following will register this pair in global memory, meaning it persists between driver instances. This means you can run this function for every type you need mapped, and never need to explicitly provide rules to the mappers in your code unless you want the default case overridden.

This includes inside the rules themselves. When writing the actingJobRules, we had to set the rule for the person property as

person: rule.asNode({
        convert: (node: Node) => node.as(Person, personRules)
    })

however, if we know we have registed personRules to Person with recordObjectMapping.register(ActingJob, actingJobRules) we can replace that with:

person: rule.asNode({
        convert: (node: Node) => node.as(Person)
    })

Not a huge change, but a bit prettier.

Naming conventions translators.

Say all the data in your database was put there by a python developer, who decided that every property on your nodes should be written in snake_case, being a reasonable JS developer, you obviously write your code with property names in camelCase! Are you really going to want to use a from: Rule configuration for every single property name made up of more than one word? Of course not!

The mapping update contains a function recordObjectMapping.translateIdentifiers() which sets a default name translation. The function takes a (string) => string function mapping from the property names in code to the names in the databse database as input. If you are reasonable enough that both sides use a relatively standard naming convention, you can get a translator with the function recordObjectMapping.getCaseTranslator(), which takes the name of both naming conventions in use and return a (string) => string translation function.

So, instead of setting a custom source key for every long property name, you can use the following line of code:

recordObjectMapping.translateIdentifiers(recordObjectMapping.getCaseTranslator("snake_case", "camelCase"))

TypeScript Decorators with Metadata (TS 5.2)

An alternative to manually writing rules for a type is also available for typescript 5.2+ users: Decorators with metadata.

NOTE: Many runtimes do not yet fully support decorator metadata. For users of Node.js the following line: (Symbol as { metadata: symbol }).metadata ??= Symbol('Symbol.metadata'); will polyfill the Symbol.metadata property. Without this polyfill or a runtime that otherwise supports symbol metadata, these decorators will not function. Due to these limitations, decorators remain in preview even with the stabilization of the rest of object mapping.

@neo4j.mappingDecorators.mappedClass()
class  Movie {
    @neo4j.mappingDecorators.stringProperty()
    title: String
    @neo4j.mappingDecorators.optionalProperty() // must be above type decorator
    @neo4j.mappingDecorators.mapPropertyFromName("released")
    @neo4j.mappingDecorators.numberProperty({isInteger: true})
    release?: number
    @neo4j.mappingDecorators.optionalProperty() // must be above type decorator
    @neo4j.mappingDecorators.stringProperty()
    tagline?: string
}

@neo4j.mappingDecorators.mappedClass()
class Person {
    @neo4j.mappingDecorators.stringProperty()
    name: string
    @neo4j.mappingDecorators.optionalProperty() // must be above type decorator
    @neo4j.mappingDecorators.numberProperty({isInteger: true})
    born?: number
}

@neo4j.mappingDecorators.mappedClass()
class Roles {
    @neo4j.mappingDecorators.listProperty()
    @neo4j.mappingDecorators.stringProperty()
    roles: string[]
    constructor(roles: string[]) {
        this.roles = roles
    }
}

@neo4j.mappingDecorators.mappedClass()
class ActingJob {
    @neo4j.mappingDecorators.convertPropertyToType(Person) // must be above type decorator
    @neo4j.mappingDecorators.nodeProperty()
    person: Person
    @neo4j.mappingDecorators.convertPropertyToType(Movie) // must be above type decorator
    @neo4j.mappingDecorators.nodeProperty()
    movie: Movie
    @neo4j.mappingDecorators.convertPropertyToType(Roles) // must be above type decorator
    @neo4j.mappingDecorators.relationshipProperty()
    roles: Roles
    @neo4j.mappingDecorators.listProperty() // must be above all other decorators
    @neo4j.mappingDecorators.convertPropertyToType(Person) // must be above type decorator
    @neo4j.mappingDecorators.nodeProperty()
    costars: Person[]
}

The above code will both define classes and register rules for them in the rules registry. The order of the decorators is important, with the type expected closest to the property, optional/conversion above that, and the listProperty() decorator on top if the property is a list.

Alternatively, to create the asObject version of the ActingJob rule:

 // This assumes we have created annotated classes for Person, Movie and Role as above.
@neo4j.mappingDecorators.mappedClass()
class ActingJob {
    @neo4j.mappingDecorators.objectProperty(Person)
    person: Person
    @neo4j.mappingDecorators.objectProperty(Movie)
    movie: Movie
    @neo4j.mappingDecorators.objectProperty(Roles)
    roles: Roles
    @neo4j.mappingDecorators.listProperty()
    @neo4j.mappingDecorators.objectProperty(Person)
    costars: Person[]
}

Mapping Rules for Parameters

As of JS Driver 6.1, the Object Mapping rules also function for Object Parameter Mapping. Allowing the same rules used to map records into object to be used to map objects to query parameters.

If your domain object is already a simple JavaScript object with all the properties in the correct format to be sent as parameters there is of course no need for any mapping to occur. But if you store temporal data as Neo4j temporal types in the database but use them as strings in your Domain Object, or if your Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission.

// This is a very strange object, but it's useful to show a couple of different examples of property types.
class Obj {
    constructor (obj) {
        this.string = obj?.string ?? 'hi'
        this.number = obj?.number ?? 1
        this.bigint = obj?.bigint ?? BigInt(1)
        this.date = obj?.date ?? "2024-01-01"
        this.time = obj?.time ?? "10:11:12.13Z"
        this.list = obj?.list ?? ["hi"]
        this.function = () => "function string" // the driver cannot send functions over bolt
        this.node = new neo4j.Node(123, [], {}) // nor can it send nodes
    }
}

const session = driver.session()
const rules = {
    number: neo4j.rule.asNumber({isInteger: true}),
    string: neo4j.rule.asString(),
    bigInt: neo4j.rule.asBigInt({acceptNumber: true}),
    date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string 
    time: neo4j.rule.asTime({from: "dob", stringify: true}),
    list: neo4j.rule.asList({ apply: neo4j.rule.asString() }),
    function: {
        parameterConversion: () => undefined, // explicitly skips the function when sending parameters
        optional: true // Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no function
    },
    node: {
        parameterConversion: () => undefined, // explicitly skips the node when sending parameters
        optional: true// Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no node
    }
}

neo4j.RecordObjectMapping.register(Obj, rules)

// This allows us to use camelCase for our object properties and snake_case for the properties on our node in the DB
neo4j.RecordObjectMapping.translateIdentifiers(neo4j.RecordObjectMapping.getCaseTranslator("snake_case", "camelCase"))

session.run(
  'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list',
  new Obj(),
  {}
)
.as(Obj) //this line then converts the returned values into a new Obj.
.then((res) => {
    console.log(res.records[0])
    session.close()
    driver.close()
})

@MaxAake
MaxAake marked this pull request as ready for review June 3, 2026 08:57
@MaxAake
MaxAake requested a review from StephenCathcart June 4, 2026 07:38

@StephenCathcart StephenCathcart left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@MaxAake
MaxAake merged commit a06109c into 6.x Jun 4, 2026
45 checks passed
@MaxAake
MaxAake deleted the pre-release-mapping-changes branch June 4, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants