← Back

December 15, 2025

The OpenCypher Driver Nobody Asked For

Larry Wall, the creator of Perl, called Laziness one of the three great virtues of a programmer:

Laziness: The quality that makes you go to great effort to reduce overall energy expenditure. It makes you write labor-saving programs that other people will find useful... Hence, the first great virtue of a programmer.

I am willing to work incredibly hard once to never have to do boring work again.

I love OpenCypher. I love the ASCII-art syntax; it looks like the graph it describes. But the drivers haven't kept up with the SQL driver ecosystem (at least on node.js). They want me to be diligent, verbose, and manual.

I don't want to be diligent. I want to be lazy.

This is the log of a "detour": time building a tool just to create a syntax that saves me ten keystrokes per query.

The Baseline: Too Much Typing

Here is standard OpenCypher execution using a well-known node.js library. I'm far too lazy to type this.

// The "Diligent" Way const session = driver.session(); try { // 1. You have to open a session // 2. You have to manage the query string and params separately // 3. You have to remember to close the session const result = await session.executeWrite((tx) => tx.run("CREATE (a:Greeting) SET a.message = $message RETURN a", { message: "Hello, World!" }) ); } finally { await session.close(); }

Values are separated from the query, forcing me to map $message to { message: ... }. I have to manage the session lifecycle manually. It feels like I'm filling out a tax form for every single query, rather than just telling the database what I want.

Iteration 1: Don't make me pass a second argument

I want to write WHERE id = ${id}.

Security engineers will scream "SQL Injection!" and they are right. If you blindly concatenate strings, you deserve what happens to you.

But I'm lazy, not reckless. So we built a template literal tag. It parses the string for me, extracts the variables, and replaces them with safe, auto-generated parameter keys ($p_0, $p_1).

// Iteration 1: The Tag // I type this: const query = cypher`MATCH (u:User) WHERE u.id = ${id} RETURN u`; // The code does this: // query.statement -> "MATCH (u:User) WHERE u.id = $p_0 RETURN u" // query.parameters -> { p_0: 123 }

Now I can be lazy and safe.

Iteration 2: Let me use that thing I wrote that other time

I found myself writing the same MATCH (u:User...) clauses over and over. Copy-pasting is the bad kind of lazy. The good kind of lazy is "Composition."

I wanted to stick one query inside another, specifically for optional fields.

const result = await cypher` MATCH (d:${Entity.DomainRegistration} {id:${id}}) SET d.updatedAt = ${now} ${updates.status ? cypher`SET d.status = ${updates.status}` : cypher``} RETURN d `;

This required the tag to be recursive. If it sees an interpolated value that is also a cypher result, it inlines the string and merges the parameters.

It also let us build helper utilities like cypher.join for constructing WHERE clauses without the trailing "AND" nightmare:

const filters = []; if (params.search) filters.push(cypher`n.name CONTAINS ${params.search}`); if (params.active) filters.push(cypher`n.active = true`); const query = cypher` MATCH (n:Item) WHERE ${cypher.join(filters, " AND ")} RETURN n `;

Iteration 3: Just await for it

We were safe. We were composable. But I was still irritated by this:

const q = cypher`...`; await driver.run(q); // Ugh.

Why do I have to tell the driver to run? The query knows it wants to be run.

I wanted to simply await the tag.

To do this, we committed a minor crime against TypeScript: we wrapped the query builder in a Proxy. The proxy intercepts any call to .then(), making the object "Thenable."

To the JavaScript runtime, the query looks like a Promise. When you await it, the Proxy secretly instantiates a session, runs the query, closes the session, and returns the result.

// Iteration 3: Magic Execution // No sessions. No .run(). Just intent. const result = await cypher`MATCH (n) RETURN n`;

Debuggable? Yes. This is a feature, not a bug. Since the object is both a definition and a Promise, you can console.log(query) to see exactly what will run (statement + params) without executing it. No special flags, no .toQuery() methods. It's safe by default, lazy by design.

Iteration 4: I don't want to rewrite this for Redis

At one point, we evaluated switching from RedisGraph to Apache Age or FalkorDB. Where we ended up doesn't matter. What does matter is that the protocols are different. The syntax is 99% the same.

Because all of our queries were wrapped in this cypher tag, I didn't have to rewrite them. Instead, I tore apart the internals of the tag and built an "Adapter" system inside the Proxy.

I built backends for:

  • Bolt (Neo4j/Memgraph)
  • RedisGraph/FalkorDB
  • Apache AGE (Postgres extension)

I was lazy enough to build a hardware abstraction layer so I wouldn't have to find-and-replace my code.

The Payoff

Was it worth spending however long building a parser, a recursive merger, and a Proxy wrapper just to avoid typing driver.run?

// The Result const res = await cypher` MATCH (u:${Entity.User}) WHERE u.id = ${userId} SET u.lastLogin = ${Date.now()} RETURN u`;

Yes. Because now the code looks exactly like my thought process. There is no translation layer between "I want this data" and "Here is the code."

That is the ultimate goal of developer experience: removing the friction between thought and execution.