Engineering

Data

Nik's devlog 0

Nik's devlog 0

Overengineering, type-safe nightmares, and the occasional switch statement.

Nikolay Alexandrov

Founding Engineer

Nikolay Alexandrov

Founding Engineer

Feb 14, 2025

Hello Querio users (present and future)! My name’s Nikolay. You might know me as a guy who spent the last 3 years writing portfolio management software in .NET or from that time my project supervisor at university commented on my intelligence. I tricked Querio team into hiring me three short weeks ago. And look, I am not going to sit here and pretend like I have anything actually insightful to tell you. No, I am taking it all the way back to 2006 LiveJournal (I was 8 years old) and share with you my subjective experience of doing my new job.

Challenge accepted.

You might think I'm making this up—I'm not. And while you may question my intelligence (the jury's still out on that one), this is genuinely what happens when you leave the safe harbor of enterprise software, where everything is Properly Architected™, and bellyflop into the world of early-stage startups.

Coming from enterprise, I can still feel new neural connections forming in previously untouched regions of my brain. Now here I am, witnessing the aftermath of Cursor's "AGI moments" scattered throughout version control (and to be fair, some of them come close). After years of change requests breaking my soul, I wonder if moving fast and breaking things might just break me faster.

hot take: js database tooling

I'd tell you to brace for controversy, but let's be honest - we're all thinking it.

During the last platform shift something happened. Like many others, I joined the fold when JavaScript was becoming the universal runtime - browsers, servers, mobile apps, smart kettles, you name it. I managed to get a job writing C# and watched from the sidelines as my peers any% speed-ran decades of architecture patterns, reinventing and reimagining them for this new stack.

Supply has followed demand. Dev-tools companies raised millions of dollars and used it to put on a decade-long magic show with only ORMs on stage and all of us in attendance.

With a flourish of a handkerchief, each covered the unruly databases. "Watch closely," they whispered, lifting the silk with practiced grace to reveal... ta-da! A pristine API with three or four meticulously documented endpoints. The audience gasped—all those tables and joins had vanished, replaced by GET, POST, and DELETE operations. The N+1 queries giggled and slipped behind the curtain.

Somewhere along the way we collectively decided that writing SQL was passé. I admit, SQL queries don't exactly fit in or around your JSX or anywhere inside the virtual DOM. Don’t know about you, but I am already stuck in a messy relationship with JS, markup and Tailwind classes, and SQL is definitely not invited to that party.

Imagine:

<div className='flex flex-col p-4'>{SELECT * FROM users WHERE id = ?}</div>

…hmmm doesen’t look half bad!

the tale of two ORMs

Here's a sentence I never thought I'd write: we use Prisma for writes and Drizzle for reads (ish). I know what you're thinking - "that's insane!" And you're right! But here's the twist: it works.

Like any good JS library, both sit there like WWE heavyweight champions, demanding endless boilerplate tributes and infinite config file homages before releasing your data. Sure, Prisma's mutations are sweet, the type safety is solid, and the schema management is chef's kiss. Thank you Prisma! But what's with the N+1 query for every relation unless explicitly included? Come on Prisma... And rolling back migrations? Hope you enjoy doing that by hand. Really, Prisma?

But let's be real, if I really cared about redundant queries, I wouldn't have 14 Claude tabs open. We face different challenges. In our monorepo, we follow the workspace pattern. We've got a Node server running Express that periodically pokes your database for context, alongside a hefty Next.js app doing its thing. Now we want to share our lovely Prisma queries and mutations with our server—but we can't! The prisma (generated) client must be lifted out of the Next app, repackaged, dropped at the top level, and reimported. Congratulations, you now get to juggle two different binary generations—assuming you've managed to untangle the package mess.

And I know what you're thinking—"just put all the Prisma code in your Express server and call it a day." Good idea! Stay tuned for that.

Anyway. We couldn't share Prisma between our two apps. May be a skill issue. Enter: Drizzle. It's runtime agnostic - no binary engines, no platform-specific code generation, just TypeScript that runs anywhere. Want to share your database layer between workspaces? It's just code - bundle it, import it, done. The queries are just SQL wrapped in a type-safe builder, so there's no magic to break when you move between environments.

The bundle size is tiny compared to Prisma's big chungus binaries. Not that this makes a difference for us today...

But it’s not all drizzle and rainbows (heh). The migrations story isn't exactly making me write home. The CLI tools are in perpetual beta, and don't get me started on trying to maintain separate migration paths for different environments.

But here's the kicker - Drizzle, or any other ORM promising smaller bundles, perfect type safety, and world peace all have one big flaw. They just ain’t inside our app! And the effort of ripping Prisma out and putting another ORM in I’d rather spend on the roadmap.

And yes I have been drinking DHH Kool-Aid and building all my own projects in Rails and ActiveRecord. The ORM situation in our JS today feels like I traded in my good-ol Prius for a two-headed dragon - it's powerful, but really scary and don’t talk to me about insurance premiums.

me vs typescript type system

Querio takes in your natural language prompts and writes SQL and Python to wrangle and visualise data for you while you’re scrolling TikTok Shop.

Users want to see the source data for their charts. What users don’t know is that we send their charts back as Plotly objects over the wire and we have only a modicum of an idea of what the chart actually is until we render it. I needed to build a chart-to-table toggle (maybe my uni professor was onto something).

There it is on the left!

When I first heard my assignment my reptilian C# brain hissed: “pattern matching”. I never made much use of higher brain function so what I did next led to the most bloody, gory and senselessly violent battle with TypeScript's type system in recorded history. I very narrowly escaped death but emerged victorious.

In the technical spirit of this article, I will let the code do the talking. I was frustrated that I had to type discriminant string by hand instead of having them IntelliSensed into the function, so I wrote

// Using the @types/plotly to narrow the types of charts coming from the Code Exec Env (CEE). 
// Simplifies handling different chart types.
// Avoids type guards (i dont like them)
// For usage see apps/app/modules/Plot/index.tsx

// get the typescript
import { Data, PlotData } from "plotly.js";

// get all the partial types
type ExtractPartialTypes<T> = T extends Partial<infer U> ? Partial<U> : never;
type AllTypes = ExtractPartialTypes<Data>;

// e.g 'scatter' | 'bar' | 'box' | 'sankey' | 'pie' | 'ohlc' | etc.
type PlotTypeDiscriminator = NonNullable<AllTypes["type"]>;

// get the Partial type from the discriminator value, default to Partial<PlotData>, e.g 'pie' => Partial<PieData> and 'scatter' = Partial<PlotData>
type ConcretePlotType<T> = Extract<Data, { type?: T }> extends never
  ? Partial<PlotData>
  : Extract<Data, { type?: T }>;

function isMatchingType<K extends PlotTypeDiscriminator>(
  data: Data,
  type: K,
): data is ConcretePlotType<K> {
  return data.type === type;
}

// this type holds handler definitions
export type HandlerRecord<R> = Partial<Record<PlotTypeDiscriminator, (data: Data) => R>> & {
  default: (data: Data) => R;
};

// takes a type descriminator and lets you mount a callback that takes the concrete plot type as an arugment
export function createHandler<K extends PlotTypeDiscriminator, R>(
  type: K,
  handler: (data: ConcretePlotType<K>) => R,
): { [P in K]: (data: Data) => R } {
  return {
    [type]: (data: Data) => {
      if (!isMatchingType(data, type)) {
        throw new Error(`Cannot create handler. Invalid type: expected ${type}`);
      }
      return handler(data);
    },
  } as { [P in K]: (data: Data) => R };
}

export function ch<K extends PlotTypeDiscriminator, R>(
  type: K,
  handler: (data: ConcretePlotType<K>) => R,
) {
  return createHandler(type, handler);
}

export function mapHandlers<T>(data: Data, _handlers: HandlerRecord<T>): T | undefined {
  if (data.type) {
    const handler = _handlers[data.type] || _handlers.default;
    return handler(data);
  }

  return undefined;
}

I thought to myself: “there is a profound insight about type theory hiding in there”. You could use my ‘pattern matching’ like this.

// the first argument to `ch` is a type-discriminant for the plotly types (see here )
// the second argument will be the strongly-typed chart
// `ch` is __total__ over all (plotly) Data types
// enjoy pattern matching!

// this function will return a ChartColumn or undefined
const chartDataHandlers: HandlerRecord<ChartColumn | undefined> = {
  ...ch("pie", ({ labels, values }) => {
    if (!values) {
      return undefined;
    }
    return {
      labels: labels as string[],
      values,
      name: "value",
      orientation: "v",
    };
  }),
  ...ch("scatter", (data) => {
    const labels = (data.orientation === "v" ? data.x : data.y) as string[];
    const values = (data.orientation === "v" ? data.y : data.x) as (string | number)[];
    return {
      labels,
      values,
      name: data.name || "value",
      orientation: data.orientation || "v",
    };
  }),
  ...ch("bar", (data) => {
    const labels = (data.orientation === "v" ? data.x : data.y) as string[];
    const values = (data.orientation === "v" ? data.y : data.x) as (string | number)[];
    return {
      labels,
      values,
      name: data.name || "value",
      orientation: data.orientation || "v",
    };
  }),
  ...ch("box", (data) => {
	  // these are not supported
    console.log(data.boxpoints);
    return undefined;
  }),
  ...ch("sankey", (data) => {
    console.log(data.hoverinfo);
    return undefined;
  }),
  ...ch("ohlc", (data) => {
    console.log(data.hoverinfo);
    return undefined;
  }),
  default: (data: Data) => {
    console.log(data);
    return undefined;
  },
};

// finally, map over the chart data
export function plotlyDataToRows(data: Data[], layout?: Partial<Layout>): TableData {
  if (!layout) {
    //TODO: log we cant turn chart into a table because no layout
    return [];
  }
  const _chartDataHandlers = (d: Data) => mapHandlers(d, chartDataHandlers);
  const chartColumns = data
    .map(_chartDataHandlers)
    .filter((d): d is ChartColumn => d !== undefined);

  return chartData2TableData(chartColumns, layout);
}

Patent pending.

I was done. I proudly wound my neck back inline with my spine, feeling fiercely intelligent. When I left the office at 22:34 on a Friday I texted my friend about my elegant extension to the language.

When I got home I realised I could have written:

function convertDataToChartColumn(data: Data): ChartColumn | undefined {
  // Use the type property as discriminant, duh
  switch (data.type) {
    case "pie":
      return handlePieChart(data);
    case "scatter":
      return handleXYChart(data);
    case "bar":
      return handleXYChart(data);
    default:
      console.log(data);
      return undefined;
  }
}

function plotlyDataToRows(data: Data[], layout?: Partial<Layout>): TableData {
  if (!layout) return [];

  const chartColumns = data
    .map(convertDataToChartColumn)
    .filter((d): d is ChartColumn => d !== undefined);

  return chartData2TableData(chartColumns, layout);
}

...and gotten the exact same type safety without conjuring an eldritch horror of generic constraints. Sometimes you can move so fast that you build an entire type-safe pattern matching framework just to appreciate a switch statement.

Whew! Time flies when you’re having fun! I am way over the word-limit and I haven’t even talked about the new agent architecture or Pedro’s new fully-websocketed Querio Explore Page that rips harder than Vector W8 Twin Turbo by Vector Aeromotive (1989-1993).

For next month’s issue of nik’s devlog I can promise takes on:

  • deploying Javascript

  • gross ORM misuse

  • ???

nik@querio.ai if you have any ideas about any of the above or just want to fight me about type-guards.

/

/

Nik's devlog 0

/

/

Nik's devlog 0

Querio

Query, report and explore data at technical level.

2024 Querio Ltd. All rights reserved.

Contacts

Our partners

Querio

Query, report and explore data at technical level.

2024 Querio Ltd. All rights reserved.

Contacts

Our partners

Querio

Query, report and explore data at technical level.

Contacts

Our partners

Terms of service

Privacy Policy

2024 Querio Ltd. All rights reserved.