DEV Community

Roman Melnikov
Roman Melnikov

Posted on • Originally published at neftedollar.github.io

Type-Safe Cypher Queries in F# — Introducing Fyper

Every F# developer who's worked with Neo4j knows the pain: raw Cypher strings, no IntelliSense, no compile-time checks, and the constant fear of typos in property names.

// The old way — string-based, error-prone
let cypher = "MATCH (p:Preson) WHERE p.agee > 30 RETURN p"
// Two typos. You'll find out at runtime. Maybe in production.
Enter fullscreen mode Exit fullscreen mode

I built Fyper to fix this. It's a type-safe Cypher query builder that uses F# computation expressions:

type Person = { Name: string; Age: int }

let query = cypher {
    for p in node<Person> do
    where (p.Age > 30)
    select p
}
// Generates: MATCH (p:Person) WHERE p.age > $p0 RETURN p
// Parameters: { p0: 30 }
Enter fullscreen mode Exit fullscreen mode

Typo in p.Agee? Compile error. Wrong type name? Compile error. Forgot to parameterize a value? Impossible — Fyper parameterizes everything by default.

How It Works

Plain F# records are your schema. No attributes, no base classes, no code generation:

type Person = { Name: string; Age: int }
type Movie = { Title: string; Released: int }
type ActedIn = { Roles: string list }
Enter fullscreen mode Exit fullscreen mode

Fyper conventions:

  • Type name → node label (Person:Person)
  • PascalCase field → camelCase property (FirstNamefirstName)
  • Relationship type → UPPER_SNAKE_CASE (ActedInACTED_IN)

Relationships

let findActors = cypher {
    for p in node<Person> do
    for m in node<Movie> do
    matchRel (p -- edge<ActedIn> --> m)
    where (p.Age > 30 && m.Released >= 2000)
    orderBy m.Released
    select (p.Name, m.Title)
}
// MATCH (p:Person) MATCH (m:Movie)
// MATCH (p)-[:ACTED_IN]->(m)
// WHERE (p.age > $p0) AND (m.released >= $p1)
// ORDER BY m.released
// RETURN p.name, m.title
Enter fullscreen mode Exit fullscreen mode

Variable-length paths work too:

matchPath (p -- edge<Knows> --> q) (Between(1, 5))
// MATCH (p)-[:KNOWS*1..5]->(q)
Enter fullscreen mode Exit fullscreen mode

Mutations

F# record update syntax for SET — only changed fields generate Cypher:

// Birthday: increment age
let birthday = cypher {
    for p in node<Person> do
    where (p.Name = "Tom")
    set (fun p -> { p with Age = p.Age + 1 })
    select p
}
// SET p.age = (p.age + $p0)

// MERGE with ON MATCH / ON CREATE
let ensurePerson = cypher {
    for p in node<Person> do
    merge { Name = "Tom"; Age = 0 }
    onMatch (fun p -> { p with Age = 50 })
    onCreate (fun p -> { p with Age = 25 })
}
Enter fullscreen mode Exit fullscreen mode

Multi-Backend: Same Query, Different Database

Write once, run on Neo4j or Apache AGE (PostgreSQL):

// Neo4j
let neo4j = new Neo4jDriver(
    GraphDatabase.Driver("bolt://localhost:7687",
        AuthTokens.Basic("neo4j", "password")))

// Apache AGE (PostgreSQL)
let age = new AgeDriver(
    NpgsqlDataSource.Create("Host=localhost;Database=mydb;..."),
    graphName = "movies")

// Same query, different backend
let! people = query |> Cypher.executeAsync neo4j
let! people = query |> Cypher.executeAsync age
Enter fullscreen mode Exit fullscreen mode

Each driver declares which Cypher features it supports. Unsupported features (like OPTIONAL MATCH on AGE) are rejected at query construction time — not at the database.

Inspect Without Executing

Debug queries without a database connection:

let cypher, params = query |> Cypher.toCypher
printfn "%s" cypher
printfn "%A" params
Enter fullscreen mode Exit fullscreen mode

Cypher Parser (Bonus)

Fyper includes a zero-dependency Cypher parser. Parse any Cypher string into a typed AST:

open Fyper.Parser

let parsed = CypherParser.parse
    "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) RETURN p.name"
// parsed.Clauses = [Match(RelPattern(...)); Return(...)]

// Roundtrip: parse → compile
let compiled = CypherCompiler.compile parsed
Enter fullscreen mode Exit fullscreen mode

Performance

Benchmarked on Apple M1 Pro:

Operation Time
Compile simple query 890 ns
Compile complex query (8 clauses) 3.2 μs
Parse Cypher string 1.2 μs
Full roundtrip (parse → compile) 2.0 μs

Get Started

dotnet add package Fyper
dotnet add package Fyper.Neo4j    # or Fyper.Age
Enter fullscreen mode Exit fullscreen mode

250 tests (unit + property-based + integration). MIT license. Zero dependencies in core.


If you've been writing raw Cypher strings from F# — try Fyper. I'd love feedback: open an issue or leave a comment here.

Top comments (0)