You're Your agents are writing TypeScript everywhere: your app, your API, your tests. Then they hit your ClickHouse® data layer and it's a different language. Custom DSL files they can't parse, a separate CLI, no autocomplete, no type checking.
We built the Tinybird TypeScript SDK so your data infrastructure is TypeScript too. This post goes under the hood: the type system design, phantom types, branded symbols, and the trick that makes it work without a build step.
Tinybird in 60 seconds
Tinybird is managed analytics infrastructure built on top of Open Source ClickHouse. Companies like Vercel, Canva, and Resend use it to ship real-time analytics at scale: throughput, latency, and reliability are table stakes for the platform.
You define datasources (tables), write SQL pipes (transformations and queries), and publish endpoints (REST APIs). Ingestion happens via the Events API, Kafka, S3, or batch imports, at any scale.
The SDK isn't about scale. Scale is what Tinybird already does. The SDK is about developer and agent experience, letting you and your AI coding tools build with your TypeScript stack in the workflow you're used to: local development, preview environments, production deployments.
Config files in a custom DSL are opaque to agents. TypeScript isn't.
In Tinybird you can define this infrastructure using .datasource and .pipe text files with a custom DSL. A datasource file looks like this:
DESCRIPTION >
Page view tracking data
SCHEMA >
timestamp DateTime,
pathname String,
session_id String,
country LowCardinality(Nullable(String))
ENGINE MergeTree
ENGINE_SORTING_KEY pathname, timestamp
And a pipe file:
DESCRIPTION >
Get the most visited pages
NODE aggregated
SQL >
%
SELECT pathname, count() AS views
FROM page_views
WHERE timestamp >= {{DateTime(start_date)}}
AND timestamp <= {{DateTime(end_date)}}
GROUP BY pathname
ORDER BY views DESC
LIMIT {{Int32(limit, 10)}}
TYPE endpoint
This works. But these files are invisible to TypeScript. Rename a column, and nothing warns you that your app's ingestion call is now broken.
Why an SDK now?
The Tinybird TypeScript SDK is not a TypeScript interface to Tinybird. There are already ClickHouse TypeScript (and other languages) SDKs, and they just solve part of the problem.
The DX problem
When your data layer is config files and your app is TypeScript, you maintain two parallel systems:
- Separate config files disconnected from your TypeScript codebase
- No type safety between your app and your data layer
- Runtime errors when column names or types change
- Two build systems, two deployment pipelines, two mental models
You write event_type in your ingestion call, but the column is actually event_name. TypeScript doesn't catch this because it doesn't know about your .datasource file. You find out when rows start landing with null values.
The infrastructure problem
We didn't build the SDK first. We built the infrastructure first.
Build and Deployment APIs. Hot-reload experience with programmatic, atomic deployments. No more complex data migrations.
Local development. A Docker-based Tinybird instance for offline development. It's not ClickHouse local, it's the full Tinybird stack running anywhere.
Branches. Ephemeral, lightweight, and isolated environments mapped to git branches. They can contain production data or connect to your production sources, so you test against real workloads.
Git integration. You (and especially agents) should push ClickHouse changes to git and that's all, period. We've been enabling this workflow for years.
These pieces mean the data layer now fits into a standard software development workflow: branch, develop, test, preview and deploy. Once that was solid, an SDK was the obvious next interface.
Your app and its data layer still have separate development cycles. The SDK bridges them with a shared type system and a single CLI.
The design philosophy
Four principles guided the SDK design:
Schema-as-code. Your data infrastructure is TypeScript, not config. It lives in your repo, goes through code review, and deploys with your CI/CD.
Type safety across the stack. From column definitions to ingestion payloads to query results. Types propagate through InferRow<>, InferParams<>, and InferOutputRow<>.
No runtime overhead. Phantom types carry type information at compile time only. The types exist for TypeScript, not for your production bundle.
Incremental adoption. Mix TypeScript definitions with existing .datasource and .pipe files. Adopt at your own pace.
Here's what the .datasource file from above looks like in TypeScript:
import { defineDatasource, t, engine } from "@tinybirdco/sdk";
export const pageViews = defineDatasource("page_views", {
schema: {
timestamp: t.dateTime(),
pathname: t.string(),
session_id: t.string(),
country: t.string().lowCardinality().nullable(),
},
engine: engine.mergeTree({
sortingKey: ["pathname", "timestamp"],
}),
});
Same schema. But now your editor knows about it. Rename pathname to path, and every ingestion call and query that references it lights up with a type error.
Under the hood: How the SDK works
The SDK has a layered architecture. Each layer has a clear responsibility:
┌─────────────────────────────────────────────────────────────┐
│ TypeScript definitions │
│ defineDatasource(), defineEndpoint(), definePipe() │
├─────────────────────────────────────────────────────────────┤
│ Schema layer │
│ Branded types, phantom types, type inference │
├─────────────────────────────────────────────────────────────┤
│ Generator layer │
│ TypeScript → .datasource/.pipe file content │
├─────────────────────────────────────────────────────────────┤
│ API layer │
│ → Tinybird /v1/build or /v1/deploy │
├─────────────────────────────────────────────────────────────┤
│ Typed client │
│ Runtime ingestion and queries with full type safety │
└─────────────────────────────────────────────────────────────┘
The top layers are compile-time. The bottom layers are runtime. The generator sits in between, and it's simpler than you might expect.
Each generateDatasource() or generatePipe() function walks the branded runtime object and concatenates strings into the .datasource or .pipe text format. No AST manipulation, no IR.
The output is plain text identical to a handwritten datafile. You can inspect it, version it, and use the generated files without the SDK.
This also means you can mix TypeScript and datafiles in the same project:
{
"include": [
"src/datasources.ts",
"src/endpoints.ts",
"connections/*.connection",
"pipes/*.pipe"
]
}
Infrastructure resources like Kafka connections, materialized views, and copy pipes can be TypeScript too, but they can also stay as datafiles. They don't appear on the typed client since your app never calls them directly. The benefit of TypeScript for these is workflow unification (same repo, same branch, same CI/CD) and agent readability.
Phantom types for compile-time inference
The core of the type system is TypeValidator. It uses phantom types: type parameters that exist at compile time but produce no runtime code.
export interface TypeValidator<
TType,
TTinybirdType extends string = string,
TModifiers extends TypeModifiers = TypeModifiers
> {
readonly _type: TType;
readonly _tinybirdType: TTinybirdType;
readonly _modifiers: TModifiers;
nullable(): TypeValidator<
TType | null,
`Nullable(${TTinybirdType})`,
TModifiers & { nullable: true }
>;
lowCardinality(): TypeValidator<
TType,
`LowCardinality(${TTinybirdType})`,
TModifiers & { lowCardinality: true }
>;
default(value: TType): TypeValidator<
TType,
TTinybirdType,
TModifiers & { hasDefault: true; defaultValue: TType }
>;
}
The type parameters map TypeScript types to ClickHouse types. Chain .nullable() or .lowCardinality() and both sides update. Nothing happens at runtime.
Builder pattern with smart modifier ordering
The fluent API lets you chain modifiers: t.string().lowCardinality().nullable(). But ClickHouse is picky about modifier ordering. LowCardinality(Nullable(String)) is valid. Nullable(LowCardinality(String)) is not.
The nullable() method handles this:
nullable() {
if (modifiers.lowCardinality) {
// Already wrapped in LowCardinality, move Nullable inside
// LowCardinality(String) → LowCardinality(Nullable(String))
const baseType = tinybirdType.replace(/^LowCardinality\((.+)\)$/, '$1');
const newType = `LowCardinality(Nullable(${baseType}))`;
return createValidator<TType | null, `LowCardinality(Nullable(${string}))`>(
newType as `LowCardinality(Nullable(${string}))`,
{ ...modifiers, nullable: true }
);
}
return createValidator<TType | null, `Nullable(${TTinybirdType})`>(
`Nullable(${tinybirdType})`,
{ ...modifiers, nullable: true }
);
}
You can chain .lowCardinality().nullable() or .nullable().lowCardinality(). Both produce the correct ClickHouse type LowCardinality(Nullable(String)). The SDK handles the rewriting.
Branded types with Symbol.for()
Each definition type is branded with Symbol.for() for runtime type guards:
const DATASOURCE_BRAND = Symbol.for("tinybird.datasource");
const PIPE_BRAND = Symbol.for("tinybird.pipe");
const VALIDATOR_BRAND = Symbol.for("tinybird.validator");
Why Symbol.for() and not Symbol()? The CLI bundles user code with esbuild, which can create duplicate module instances. Symbol.for() uses the global registry, so isDatasourceDefinition() works even when the SDK is loaded twice.
esbuild for schema loading
The CLI evaluates your TypeScript definitions at runtime using esbuild:
await esbuild.build({
entryPoints: [schemaPath],
outfile,
bundle: true,
platform: "node",
format: "esm",
target: "node18",
external: ["@tinybirdco/sdk"],
sourcemap: "inline",
minify: false,
});
const module = await import(`file://${outfile}`);
The key line is external: ["@tinybirdco/sdk"]. If esbuild bundled the SDK into the output, we'd get duplicate brand symbols and the type guards would break. Keeping it external means the bundled code resolves to the same SDK instance the CLI uses.
After loading, the CLI scans exports for branded definitions and collects them.
The type system in action
Using the pageViews datasource from the design philosophy section, define an endpoint with typed params and output:
import { defineEndpoint, node, t, p } from "@tinybirdco/sdk";
export const topPages = defineEndpoint("top_pages", {
params: {
start_date: p.dateTime(),
end_date: p.dateTime(),
limit: p.int32().optional(10),
},
nodes: [
node({
name: "aggregated",
sql: `
%
SELECT pathname, count() AS views
FROM page_views
WHERE timestamp >= {{DateTime(start_date)}}
AND timestamp <= {{DateTime(end_date)}}
GROUP BY pathname
ORDER BY views DESC
LIMIT {{Int32(limit, 10)}}
`,
}),
],
output: {
pathname: t.string(),
views: t.uint64(),
},
});
The SQL inside node() is a plain string, so TypeScript can't check it. That validation happens at build time: tinybird dev and tinybird build run the SQL against ClickHouse and fail fast if it's invalid.
Wire both into the typed client:
import { Tinybird } from "@tinybirdco/sdk";
import { pageViews } from "./datasources";
import { topPages } from "./pipes";
export const tinybird = new Tinybird({
datasources: { pageViews },
pipes: { topPages },
});
Now ingestion and queries are fully typed. The .ingest() call hits the Events API, which handles batching and buffering server-side (Vercel pushes 14.5B rows/day through it). The typed client adds no overhead on top.
Rename pathname to path in your datasource, and TypeScript flags every call site:
await tinybird.pageViews.ingest({
timestamp: "2024-01-15 10:30:00",
pathname: "/pricing",
// ^^^^^^^^ Error: 'pathname' does not exist in type
// '{ timestamp: string; path: string; session_id: string; country: string | null }'
session_id: "abc123",
country: "US",
});
You catch this at compile time, not in production. Runtime errors (network failures, bad params) throw a TinybirdError with the HTTP status code and response body.
What about the data already in ClickHouse? When you rename or change a column, forward queries handle the migration on the deployed side.
They run a backfill against live data, and if the migration fails (say, casting a string to UUID on invalid values), the deployment is discarded and your live version stays untouched.
The development workflow
The type system is half of it. The other half is how the SDK connects to the infrastructure layer we described earlier (branches, deployment APIs, git integration, local development) and turns them into a standard development loop.
Local → branch → production
The CLI has four commands that map to how you already work:
tinybird devwatches your files and bundles TypeScript on save, pushing to a local container or a cloud branch.tinybird buildpushes to a Tinybird branch. Blocked on main for safety.tinybird previewcreates an ephemeraltmp_ci_{branch}environment for CI. Deploys with production-like semantics, then tears down.tinybird deploypromotes to production via/v1/deploy. Only runs on main.
These aren't wrappers around a web UI. They hit the build and deployment APIs directly: POST /v1/build for branches, POST /v1/deploy for production, with atomic staging-to-live promotion.
When you run tinybird dev, the CLI watches your files. On every save it bundles your TypeScript with esbuild, generates the datafiles, and posts them to the build API.
The build API validates everything: schema parsing, engine configuration, naming conflicts, and it runs your SQL against ClickHouse. If anything fails, errors show inline with the resource name and what broke.
The feedback loop works like tsc --watch or next dev: save, validate, see errors. The difference is validation runs against a real ClickHouse instance (local or cloud), so you catch SQL errors, not just type errors.
Token resolution
The SDK automatically figures out which environment to talk to. The resolveToken() function checks, in order:
TINYBIRD_BRANCH_TOKEN, an explicit override- Preview environment detection: if running in Vercel, GitHub Actions, GitLab CI, CircleCI, Azure Pipelines, or Bitbucket, it reads the branch name from the CI environment, fetches the matching branch token from the API, and caches it
TINYBIRD_TOKEN, the workspace token (production fallback)
This means your app code doesn't change between environments:
// In the web analytics starter kit
export function createAnalyticsClient(options?: { token?: string; baseUrl?: string; devMode?: boolean }) {
return new Tinybird({
token: options?.token ?? process.env.TINYBIRD_TOKEN,
baseUrl: options?.baseUrl ?? process.env.TINYBIRD_HOST,
devMode: options?.devMode ?? process.env.NODE_ENV === "development",
datasources: { analyticsEvents, /* ... */ },
pipes: { topPages, topSources, /* ... */ },
configDir: dirname(fileURLToPath(import.meta.url)),
});
}
When devMode is true, the client detects your git branch, resolves the branch token, and routes queries to the branch environment. When false, it uses the workspace token and hits production. Same code, same client instance.
A real example: web analytics starter kit
Tinybird's web analytics starter kit is built entirely with the SDK. The tinybird/ directory is a standalone package (check npm for the latest version):
{
"scripts": {
"dev": "tinybird dev",
"build": "tinybird build",
"deploy": "tinybird deploy",
"preview": "tinybird preview"
},
"dependencies": {
"@tinybirdco/sdk": "^0.0.51"
}
}
The config tells the CLI where to find definitions and how to connect:
{
"include": [
"src/datasources.ts",
"src/endpoints.ts",
"src/materializations.ts",
"src/pipes.ts",
"src/copies.ts",
"src/tokens.ts",
"src/client.ts"
],
"token": "${TINYBIRD_TOKEN}",
"baseUrl": "${TINYBIRD_HOST}",
"devMode": "branch"
}
The devMode: "branch" setting means tinybird dev creates cloud branches (not local). The CLI reads your git branch and creates or reuses a Tinybird branch with that name. Each developer gets an isolated environment. Switch git branches, and the CLI switches Tinybird environments.
The CI workflow runs on every pull request. It spins up a tinybird-local container, builds against it, then creates a preview branch in the cloud:
# .github/workflows/tinybird-ci.yml
on:
pull_request:
branches: [main]
env:
TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }}
TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }}
jobs:
ci:
runs-on: ubuntu-latest
services:
tinybird:
image: tinybirdco/tinybird-local:latest
ports:
- 7181:7181
steps:
- uses: actions/checkout@v4
# ... install deps ...
- run: pnpm run tinybird:build -- --local
- run: pnpm run tinybird:preview
The CD workflow triggers on push to main and deploys to production:
# .github/workflows/tinybird-cd.yml
on:
push:
branches: [main]
jobs:
cd:
steps:
# ... install deps ...
- run: pnpm run tinybird:deploy
tinybird:build --local validates your definitions against the local container. tinybird:preview creates a tmp_ci_{branch} environment in the cloud so Vercel preview deployments pick up the branch token automatically. When the PR merges, tinybird:deploy promotes to production.
You can also test against tinybird-local with your own test framework, seed data as you would in production, and assert on query results.
Your app and your data layer ship in the same PR, but they deploy independently. Keep schema changes additive: add columns before your app sends them, stop sending before you remove them. The SDK makes the contract visible, not the deployment atomic.
Try it
The SDK is @tinybirdco/sdk on npm. Source is on GitHub.
npm install @tinybirdco/sdk # or pnpm add, yarn add, bun add
npx tinybird init
- Quickstart guide
- SDK resource definitions: datasources, endpoints, materialized views, Kafka connections, copy pipes, JWT tokens
- CLI commands
TypeScript is the first SDK, not the last. We're building SDKs for other languages with the same approach: strongly typed, integrated with the CLI and the infrastructure layer described in this post.
If you run into issues or have ideas, open an issue on the repo.
