Back to Blog

Type-Safe SQL in TypeScript: Why Your Current Approach is Failing You (And What to Do About It)

Developer productivity suffers when SQL queries break at runtime instead of compile time. Type-safe SQL in TypeScript isn't just a nice-to-have, it's essential for building reliable applications. This guide explores why traditional approaches fall short and shows you how modern solutions like hypequery can transform your development workflow.

The Hidden Cost of Runtime SQL Errors

Here’s the scenario: you deploy your application, confident that your type checking caught all potential issues, only to discover SQL-related runtime errors in production. Despite TypeScript’s powerful static analysis, database queries remain a significant blind spot in most applications.

Consider this common pattern:

// Traditional approach - looks safe, but isn't
const getUserById = async (id: number) => {
  const query = `SELECT id, name, email FROM users WHERE id = ${id}`;
  const result = await client.query(query);
  return result.rows[0]; // What type is this? TypeScript doesn't know!
};

// Usage - no compile-time safety
const user = await getUserById(123);
console.log(user.invalidProperty); // Runtime error!

This approach suffers from multiple critical issues:

  1. SQL injection vulnerabilities through string concatenation
  2. No compile-time validation of column names or table structure
  3. Runtime type mismatches when database schema changes
  4. Lost productivity from debugging issues that should be caught at compile time

The ts-sql-query Approach

ts-sql-query was one of the first libraries to tackle type-safe SQL in TypeScript. While it provides compile-time query validation, it has several limitations:

// ts-sql-query example
const customerWithId = connection.selectFrom(tCustomer)
  .where(tCustomer.id.equals(customerId))
  .select({
    id: tCustomer.id,
    firstName: tCustomer.firstName,
    lastName: tCustomer.lastName
  })
  .executeSelectOne();

Limitations:

  • Supports multiple databases but lacks ClickHouse-specific optimisations
  • Complex setup for advanced features like streaming queries
  • Limited support for modern analytics workloads

The Kysely Solution

Kysely has gained popularity as a type-safe query builder for Node.js. It offers strong TypeScript integration:

// Kysely example
const user = await db
  .selectFrom('users')
  .where('id', '=', 1)
  .select(['id', 'name', 'email'])
  .executeTakeFirst();

Strengths:

  • Excellent TypeScript inference
  • Clean, SQL-like syntax
  • Strong community support

Limitations:

  • No ClickHouse support out of the box
  • Complex joins require verbose syntax
  • Missing analytics-specific features like cross-filtering

The ORM Dilemma: Prisma vs. Drizzle

Modern ORMs like Prisma and Drizzle provide type safety but with different tradeoffs:

Prisma:

  • Schema-first approach with code generation
  • Excellent type safety but heavy abstraction
  • Not optimised for ClickHouse or analytics workloads

Drizzle:

  • Code-first, lightweight approach
  • SQL-like queries with TypeScript types
  • Limited ClickHouse ecosystem support

According to recent performance comparisons, Drizzle generally outperforms Prisma in speed tests, particularly in serverless environments, while Prisma focuses more on developer experience and ease of use.

The ClickHouse Challenge: Why Analytics Needs Special Treatment

ClickHouse has emerged as the go-to database for analytics and dashboards, but connecting it to TypeScript applications safely remains challenging. The official ClickHouse JavaScript client provides basic TypeScript support but lacks query-level type safety:

// Official ClickHouse client - minimal type safety
const client = createClient({
  host: 'http://localhost:8123',
  username: 'default',
  password: 'password'
});

const resultSet = await client.query({
  query: 'SELECT * FROM events WHERE date > {date:Date}',
  query_params: { date: '2023-01-01' }
});

// result.json() returns `any` - no type safety!
const data = await resultSet.json();

hypequery: The Type-Safe Solution Built for ClickHouse

hypequery addresses these challenges with a ClickHouse-first approach to type-safe SQL in TypeScript. Here’s how it transforms the developer experience:

Complete Type Safety from Query to Result

import { createQueryBuilder } from '@hypequery/clickhouse';

interface Schema {
  users: {
    id: 'Int32';
    name: 'String';
    email: 'String';
    created_at: 'DateTime';
  }
}

const db = createQueryBuilder<Schema>();

// Fully type-safe query
const user = await db
  .table('users')
  .where('id', 'eq', 123)
  .select(['id', 'name', 'email'])
  .executeTakeFirst();

// TypeScript knows exactly what properties are available
console.log(user.name); // ✅ Type-safe
console.log(user.invalidProperty); // ❌ Compile-time error

Advanced Analytics Features

Unlike general-purpose query builders, hypequery provides analytics-specific features built specifically for ClickHouse:

// Streaming large result sets
const stream = await db
  .table('events')
  .where('date', 'gte', '2024-01-01')
  .select(['user_id', 'event_type', 'timestamp'])
  .stream();

// Process data in chunks without memory issues
for await (const chunk of stream) {
  console.log(`Processing ${chunk.length} rows`);
}

// Cross-filtering for dashboard queries
const filter = new CrossFilter()
  .add({ column: 'region', operator: 'eq', value: 'US' })
  .add({ column: 'date', operator: 'gte', value: '2024-01-01' });

const revenueQuery = db
  .table('sales')
  .crossFilter(filter)
  .select(['product_id'])
  .sum('revenue', 'total_revenue')
  .groupBy('product_id')
  .execute();

Time-Based Analytics

ClickHouse excels at time-series data, and hypequery makes it easy to leverage these capabilities:

// Time-based aggregations
const hourlyStats = await db
  .table('events')
  .select(['event_type'])
  .where('timestamp', 'gte', '2024-01-01')
  .toStartOfInterval('hour', 'timestamp')
  .count('id', 'event_count')
  .groupBy(['event_type', 'timestamp'])
  .orderBy('timestamp', 'ASC')
  .execute();

// Date part extraction
const monthlyRevenue = await db
  .table('sales')
  .select(['product_id'])
  .datePart('month', 'created_at', 'month')
  .sum('amount', 'monthly_revenue')
  .groupBy(['product_id', 'month'])
  .execute();

Real-World Impact: From Hours to Minutes

The difference between traditional approaches and hypequery becomes clear in real development scenarios:

Before hypequery (Traditional Approach)

// Hours of debugging runtime errors
const getUserStats = async (userId: number) => {
  const query = `
    SELECT 
      u.name,
      COUNT(o.id) as order_count,
      SUM(o.amount) as total_spent
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.id = ${userId}
    GROUP BY u.id, u.name
  `;
  
  const result = await client.query(query);
  return result.rows[0]; // TypeScript has no idea what this contains
};

// Runtime errors when schema changes
const stats = await getUserStats(123);
console.log(stats.orderCount); // Runtime error: property doesn't exist

After hypequery (Type-Safe Approach)

// Compile-time safety, immediate feedback
const getUserStats = async (userId: number) => {
  return await db
    .table('users')
    .leftJoin('orders', 'users.id', 'orders.user_id')
    .where('users.id', 'eq', userId)
    .select(['users.name'])
    .count('orders.id', 'order_count')
    .sum('orders.amount', 'total_spent')
    .groupBy('users.id', 'users.name')
    .executeTakeFirst();
};

// TypeScript knows exactly what's available
const stats = await getUserStats(123);
console.log(stats.order_count); // ✅ Type-safe, autocomplete works
console.log(stats.invalidProperty); // ❌ Compile-time error

Getting Started with hypequery

Ready to transform your TypeScript + ClickHouse development experience? Here’s how to get started:

Installation

npm install @hypequery/clickhouse

Basic Setup

import { createQueryBuilder } from '@hypequery/clickhouse';

interface YourSchema {
  users: {
    id: 'Int32';
    name: 'String';
    email: 'String';
  }
  // Define your other tables...
}

const db = createQueryBuilder<YourSchema>({
  host: 'http://localhost:8123',
  username: 'default',
  password: 'your_password'
});

Your First Type-Safe Query

const users = await db
  .table('users')
  .select(['id', 'name', 'email'])
  .where('created_at', 'gte', '2024-01-01')
  .orderBy('name', 'ASC')
  .limit(10)
  .execute();

// TypeScript knows the exact structure of each user
users.forEach(user => {
  console.log(`${user.name} (${user.email})`); // ✅ Fully type-safe
});

Conclusion

Type-safe SQL in TypeScript isn’t just a luxury—it’s essential for building reliable, maintainable applications. While traditional approaches and general-purpose libraries fall short, hypequery provides a ClickHouse-first solution that delivers:

  • Complete compile-time safety from query to result
  • Analytics-specific features built for ClickHouse
  • Streaming support for large datasets
  • Cross-filtering for interactive dashboards
  • Time-based analytics with native ClickHouse functions

Stop debugging runtime SQL errors. Start building with confidence using hypequery’s type-safe approach to ClickHouse development.

Ready to transform your development workflow? Get started with hypequery today.