Type-safe SQL query builder for the CDP Data API. Build ClickHouse SQL queries with full IntelliSense for table/column names, and get compile-time result type inference from the builder chain.
import { cdp, type InferResult } from 'typed-cdp-sql';
const query = cdp
.selectFrom('base.events')
.select(['address', 'event_name', 'block_number'])
.where('event_signature', '=', 'Transfer(address,address,uint256)')
.where('block_number', '>', 1000000)
.limit(10)
.compile();
query.sql;
// "SELECT address, event_name, block_number FROM base.events
// WHERE event_signature = 'Transfer(address,address,uint256)' AND block_number > 1000000
// LIMIT 10"
type Result = InferResult<typeof query>;
// { result: Array<{ address: `0x${string}`; event_name: string; block_number: `${number}` }> }bun add typed-cdp-sql
# or
npm install typed-cdp-sqlRequires TypeScript >= 5.
Use the cdp singleton to start building queries:
import { cdp, type InferRow } from 'typed-cdp-sql';
// Select specific columns
const q1 = cdp
.selectFrom('base.blocks')
.select(['block_number', 'block_hash', 'gas_used'])
.compile();
// sql: "SELECT block_number, block_hash, gas_used FROM base.blocks"
// InferRow: { block_number: `${number}`; block_hash: `0x${string}`; gas_used: `${number}` }
// Select all columns
const q2 = cdp
.selectFrom('base.blocks')
.selectAll()
.compile();
// sql: "SELECT * FROM base.blocks"
// InferRow: { block_number: `${number}`; block_hash: `0x${string}`; miner: `0x${string}`; timestamp: string; ... }
// Chained selects accumulate
const q3 = cdp
.selectFrom('base.blocks')
.select('block_number')
.select('miner')
.compile();
// sql: "SELECT block_number, miner FROM base.blocks"
// InferRow: { block_number: `${number}`; miner: `0x${string}` }
// Column aliases
const q4 = cdp
.selectFrom('base.blocks')
.select('block_number as num')
.compile();
// sql: "SELECT block_number AS num FROM base.blocks"
// InferRow: { num: `${number}` }
// WHERE, ORDER BY, GROUP BY, LIMIT
const q5 = cdp
.selectFrom('base.events')
.select(['address', 'event_name'])
.where('block_number', '>', 1000000)
.orderBy('block_number', 'desc')
.limit(100)
.compile();
// sql: "SELECT address, event_name FROM base.events WHERE block_number > 1000000 ORDER BY block_number DESC LIMIT 100"
// InferRow: { address: `0x${string}`; event_name: string }
// Multiple WHERE calls are ANDed
const q6 = cdp
.selectFrom('base.events')
.select('address')
.where('event_signature', '=', 'Transfer(address,address,uint256)')
.where('block_number', '>', 1000000)
.compile();
// sql: "SELECT address FROM base.events WHERE event_signature = 'Transfer(address,address,uint256)' AND block_number > 1000000"
// InferRow: { address: `0x${string}` }For complex selections (map access, functions, casting, raw SQL), use a callback with the expression builder:
import { cdp } from 'typed-cdp-sql';
const query = cdp
.selectFrom('base.events')
.select((eb) => [
// Map access: parameters['key']
eb.map('parameters', 'from').as('sender'),
eb.map('parameters', 'value').as('amount'),
// Aggregate functions
eb.count().as('total'),
eb.sum(eb.col('block_number')).as('block_sum'),
// String functions
eb.lower(eb.col('address')).as('lower_addr'),
eb.upper(eb.col('event_name')).as('upper_name'),
eb.replaceAll(eb.col('address'), eb.val('0x'), eb.val('')).as('clean'),
// Date/time functions
eb.toStartOfHour(eb.col('timestamp')).as('hour'),
eb.toDate(eb.col('timestamp')).as('date'),
// CAST and :: syntax
eb.cast(eb.map('parameters', 'value'), 'UInt256').as('cast_value'),
eb.castAs(eb.map('parameters', 'totalClaimed'), 'UInt256').as('double_colon'),
// Type conversion + arithmetic
eb.divide(
eb.toUInt256(eb.col('block_number')),
eb.val(1000),
).as('divided'),
// Raw SQL escape hatch (for anything not covered)
eb.raw<string>("formatDateTime(timestamp, '%Y-%m-%d')").as('formatted'),
])
.compile();
// sql: "SELECT parameters['from'] AS sender, parameters['value'] AS amount, count(*) AS total,
// sum(block_number) AS block_sum, lower(address) AS lower_addr, upper(event_name) AS upper_name,
// replaceAll(address, '0x', '') AS clean, toStartOfHour(timestamp) AS hour,
// toDate(timestamp) AS date, CAST(parameters['value'] AS UInt256) AS cast_value,
// parameters['totalClaimed']::UInt256 AS double_colon,
// divide(toUInt256(block_number), 1000) AS divided,
// formatDateTime(timestamp, '%Y-%m-%d') AS formatted
// FROM base.events"
// InferRow: {
// sender: string | boolean; amount: string | boolean;
// total: `${number}`; block_sum: `${number}`;
// lower_addr: string; upper_name: string; clean: string;
// hour: string; date: string;
// cast_value: `${number}`; double_colon: `${number}`;
// divided: `${number}`; formatted: string;
// }const query = cdp
.selectFrom('base.events')
.select('address')
// Simple comparison
.where('block_number', '>', 1000)
// Expression callback for complex conditions
.where((eb) =>
eb.or(
eb.eq(eb.col('address'), '0x1234'),
eb.eq(eb.col('address'), '0x5678'),
),
)
.compile();
// sql: "SELECT address FROM base.events WHERE block_number > 1000 AND (address = '0x1234' OR address = '0x5678')"
// InferRow: { address: `0x${string}` }const query = cdp
.selectFrom('base.events')
.select((eb) => [
eb.map('parameters', 'coin').as('coin'),
eb.count().as('total'),
])
.groupBy((eb) => eb.map('parameters', 'coin'))
.compile();
// sql: "SELECT parameters['coin'] AS coin, count(*) AS total FROM base.events GROUP BY parameters['coin']"
// InferRow: { coin: string | boolean; total: `${number}` }const query = cdp
.selectFrom('base.blocks')
.innerJoin(
'base.transactions',
'base.blocks.block_number', '=', 'base.transactions.block_number',
)
.select(['block_hash', 'transaction_hash', 'from_address'])
.limit(10)
.compile();
// sql: "SELECT block_hash, transaction_hash, from_address FROM base.blocks
// INNER JOIN base.transactions ON base.blocks.block_number = base.transactions.block_number LIMIT 10"
// InferRow: { block_hash: `0x${string}`; transaction_hash: `0x${string}`; from_address: `0x${string}` }
// With table aliases
const q2 = cdp
.selectFrom('base.blocks')
.innerJoin('base.transactions as t', 'base.blocks.block_number', '=', 't.block_number')
.select(['block_hash', 't.transaction_hash'])
.compile();
// sql: "SELECT block_hash, t.transaction_hash FROM base.blocks
// INNER JOIN base.transactions AS t ON base.blocks.block_number = t.block_number"
// InferRow: { block_hash: `0x${string}`; transaction_hash: `0x${string}` }innerJoin, leftJoin, rightJoin, and fullJoin are supported.
const query = cdp
.with('recent_blocks', (qb) =>
qb.selectFrom('base.blocks')
.select(['block_number', 'miner'])
.orderBy('block_number', 'desc')
.limit(100),
)
.selectFrom('recent_blocks')
.select(['block_number', 'miner'])
.compile();
// sql: "WITH recent_blocks AS (SELECT block_number, miner FROM base.blocks ORDER BY block_number DESC LIMIT 100)
// SELECT block_number, miner FROM recent_blocks"
// InferRow: { block_number: `${number}`; miner: `0x${string}` }CTEs can reference previously defined CTEs:
const query = cdp
.with('step1', (qb) =>
qb.selectFrom('base.blocks').select('block_number'),
)
.with('step2', (qb) =>
qb.selectFrom('step1').select('block_number'),
)
.selectFrom('step2')
.select('block_number')
.compile();
// sql: "WITH step1 AS (SELECT block_number FROM base.blocks), step2 AS (SELECT block_number FROM step1)
// SELECT block_number FROM step2"
// InferRow: { block_number: `${number}` }By default, eb.map('parameters', key) returns the broad variant type string | boolean. When you provide an ABI, parameter types are inferred from the event signature:
import { makeCdpQueryCreator, type InferRow } from 'typed-cdp-sql';
// ABI must use `as const` for literal type inference
const erc20Abi = [
{
type: 'event',
name: 'Transfer',
inputs: [
{ indexed: true, name: 'from', type: 'address' },
{ indexed: true, name: 'to', type: 'address' },
{ indexed: false, name: 'value', type: 'uint256' },
],
},
] as const;
const cdpWithAbi = makeCdpQueryCreator({ abi: erc20Abi });
const query = cdpWithAbi
.selectFrom('base.events')
.where('event_signature', '=', 'Transfer(address,address,uint256)')
.select((eb) => [
eb.map('parameters', 'from').as('sender'),
eb.map('parameters', 'to').as('recipient'),
eb.map('parameters', 'value').as('amount'),
])
.compile();
// sql: "SELECT parameters['from'] AS sender, parameters['to'] AS recipient, parameters['value'] AS amount
// FROM base.events WHERE event_signature = 'Transfer(address,address,uint256)'"
// InferRow: { sender: `0x${string}`; recipient: `0x${string}`; amount: `${number}` }
type Row = InferRow<typeof query>;
// { sender: `0x${string}`; recipient: `0x${string}`; amount: `${number}` }The ABI is purely type-level — no runtime overhead. The where('event_signature', '=', sig) call narrows the event context, and map() resolves parameter types accordingly. Without the where narrowing, types fall back to the variant union.
Note: ABIs must be defined with
as constin TypeScript (or imported from a.tsfile withas constexport). JSON imports widen string values tostring, losing the literal types needed for inference. This matches the pattern used by viem and wagmi.
import { cdp, type InferResult, type InferRow } from 'typed-cdp-sql';
const query = cdp.selectFrom('base.blocks').select(['block_number', 'miner']).compile();
// Full response shape
type Result = InferResult<typeof query>;
// { result: Array<{ block_number: `${number}`; miner: `0x${string}` }> }
// Single row type
type Row = InferRow<typeof query>;
// { block_number: `${number}`; miner: `0x${string}` }All tables are under the base.* namespace (unqualified names also work):
| Table | Key columns |
|---|---|
base.blocks |
block_number, block_hash, miner, timestamp, gas_used, ... |
base.events |
event_name, event_signature, parameters, address, topics, ... |
base.transactions |
transaction_hash, from_address, to_address, value, input, ... |
base.encoded_logs |
block_number, address, topics, transaction_hash, ... |
base.transfers |
token_address, from_address, to_address, value, ... |
| CDP SQL Type | TypeScript Type | Notes |
|---|---|---|
uint64, uint256, Int256 |
`${number}` |
Stringified numbers from API |
String |
string |
Plain text fields |
Hex (addresses, hashes) |
`0x${string}` |
Compatible with viem/abitype |
DateTime, DateTime64 |
string |
ISO 8601 timestamps |
Bool |
boolean |
|
Int8 |
number |
Small integers (e.g., action) |
uint32 |
number |
Small integers (e.g., log_index) |
Array(T) |
MapSqlType<T>[] |
Recursive |
Map(K, V) |
Record<string, MapSqlType<V>> |
|
Variant(Bool, Int256, String, uint256) |
boolean | \${number}` | string` |
Event parameter values |
For simple queries, you can also use the zero-runtime TypedQuery type to infer result types directly from SQL string literals:
import type { TypedQuery } from 'typed-cdp-sql';
type Result = TypedQuery<"SELECT block_number, block_hash FROM base.blocks LIMIT 10">;
// { result: Array<{ block_number: `${number}`; block_hash: `0x${string}` }> }This approach has no runtime code and works well for straightforward queries, but cannot handle complex features like CTEs, nested functions, or :: casting syntax due to TypeScript string type inference limits. For those, use the builder API.
MIT