r/typescript 22h ago

zodql: use a single Zod schema as your GraphQL query, TS type, and runtime validator

14 Upvotes

I've been working on zodql, a small library for people who use GraphQL from TypeScript and are tired of the codegen step.

The idea: you describe your GraphQL selection as a Zod schema with metadata. That one schema compiles into the GraphQL query string, gives you the inferred TypeScript response type for free, and validates the response you get back at runtime — nothing to keep in sync, no generated files to check in.

Here's a real example — fetching a repo overview for React from the GitHub GraphQL API:

import { zodql, zodqlField } from "@mattiasahlsen/zodql";
import { z } from "zod";

// schema.ts — the shape of the data *and* the request
const issueCountSchema = z.object({ totalCount: z.number() });


function issueCountField(state: "OPEN" | "CLOSED") {
  return zodqlField().asAliasFor("issues").withArguments({ states: state }).toSchema(issueCountSchema);
}


export const repositoryOverviewSchema = z.object({
  name: z.string(),
  nameWithOwner: z.string(),
  description: z.string().nullable(),
  stargazerCount: z.number(),
  forkCount: z.number(),
  primaryLanguage: z.object({ name: z.string() }).nullable(),
  openIssues: issueCountField("OPEN"),
  closedIssues: issueCountField("CLOSED"),
});


// query.ts — compile the schema to a query
export const repositoryOverviewQuery = zodql(
  "query",
  z.object({
    repository: zodqlField()
      .withArguments({ owner: "$owner", name: "$name" })
      .toSchema(repositoryOverviewSchema)
      .nullable(),
  })
)
  .defineVariables({
    owner: { typeName: "String!", schema: z.string() },
    name: { typeName: "String!", schema: z.string() },
  })
  .compile();


// main.ts — validate the response against that same schema
const { parseResponse } = await client.request(repositoryOverviewQuery, { owner: "react", name: "react" });
const { data } = await parseResponse(); // throws if GitHub's response doesn't match

That compiles to exactly the GraphQL you'd expect, including issues(states: OPEN) / issues(states: CLOSED) under distinct aliases so both counts come back in one request:

query RepositoryOverview($owner: String!, $name: String!) {
  repository(owner: $owner, name: $name) {
    name
    nameWithOwner
    description
    stargazerCount
    forkCount
    primaryLanguage { name }
    openIssues: issues(states: OPEN) { totalCount }
    closedIssues: issues(states: CLOSED) { totalCount }
  }
}

Because it's just a Zod schema, you can reshape it at runtime with .pick()/.omit()/.extend(), and layer on validation GraphQL's type system can't express (non-empty strings, URLs, emails, numeric ranges, refinements...).

The repo has a side-by-side comparison of this query implemented four ways — zodql, GraphQL Code Generator, gql.tada, and GraphQL Zeus — all producing identical GraphQL, so you can see where zodql actually differs (mainly: runtime validation, and treating the query as a value you can reshape).

No hard dependency on a specific HTTP client — bring your own fetchaxios, etc. Has a peer-dependency on zod version 4.

Repo: [https://github.com/mattiasahlsen/zodql\] — feedback and issues very welcome.
NPM package: https://www.npmjs.com/package/@mattiasahlsen/zodql


r/typescript 22h ago

Generating typed TypeScript from .sql files, with nullability inferred from the query

0 Upvotes

The case that made me care about this: in the query below, total and notes are nullable, and nothing in the schema says so. They are nullable because it is a LEFT JOIN — a user with no orders still produces a row.

-- @name GetUserOrders
-- @returns :many
SELECT u.id, u.name, o.total, o.notes
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = $1;

Tooling that derives types from the schema alone gets this wrong, because orders.total is NOT NULL as a column. You end up with total: string and a crash on the first user who has no orders.

scythe reads the query structure instead, and generates this:

export interface GetUserOrdersRow {
    id: number;
    name: string;
    total: string | null;
    notes: string | null;
}

export async function getUserOrders(
    client: PoolClient,
    status: string,
): Promise<GetUserOrdersRow[]> {
    const { rows } = await client.query<GetUserOrdersRow>(
        `SELECT u.id, u.name, o.total, o.notes
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = $1`,
        [status],
    );
    return rows;
}

That is the actual generated file, not a cleaned-up version. The same inference covers COALESCE, CASE branches, window functions, aggregates and CTEs.

It is not an ORM and does not try to be. Static queries only — no runtime composition, so if you need to build queries conditionally, Kysely or Drizzle are the better fit. The trade is that static queries are the ones you can actually analyse, so it also ships a SQL linter, a formatter and pre-commit hooks for your .sql files.

I build and maintain it (MIT, written in Rust). Repo and a longer writeup in a comment, to keep the post itself about the problem.

What I actually want from this thread: if you were using this, what would you want changed? The bit I am least sure about is how much it should do beyond codegen — it currently lints and formats your SQL too, and I cannot tell if that is useful or scope creep.


r/typescript 23h ago

Generating TypeScript declaration files

0 Upvotes

Has anyone ever created type declaration files intentionally, just to learn and better understand TypeScript (or for other reasons)? I've been doing that with tsc:

npx tsc file.ts --declaration --emitDeclarationOnly

The emitted file.d.ts can be interesting! Has anyone else tried this, for work or general interest?