@tetalang/teta
v0.5.3
Published
Type-safe SQL EDSL for building composable queries.
Readme
@tetalang/teta
Type-safe SQL EDSL frontend for TypeScript.
Published as @tetalang/teta on npm and @teta/teta on JSR.
@tetalang/teta builds typed query IR and keeps direct convenience helpers such as toSql(...). The reusable SQL backend lives in @tetalang/sql.
When another language needs to produce the same query representation, use the
Portable IR v1 guide and render it through
@tetalang/sql.
Install
Deno
deno add jsr:@teta/tetaJSR installations use @teta/teta and @teta/sql import specifiers. The
examples below use the npm @tetalang scope.
Bun
bun add @tetalang/tetaNode.js
npm install @tetalang/tetaQuick start
import { and, asc, eq, filter, gte, map, sort, table, t, take, toSql, pipe } from "@tetalang/teta";
const users = table("users", {
id: t.int(),
email: t.string(),
active: t.boolean(),
age: t.int(),
});
const activeUsers = pipe(
users,
filter((user) => and(eq(user.active, true), gte(user.age, 18))),
map((user) => ({ id: user.id, email: user.email })),
sort((user) => asc(user.email)),
take(10)
);
console.log(toSql(activeUsers, { dialect: "postgresql", format: "pretty" }));The root entrypoint remains the easiest import path. For narrower module boundaries, the package also exposes subpath entrypoints:
import { table, t, filter } from "@tetalang/teta/query";
import { eq } from "@tetalang/teta/expr";
import { pipe } from "@tetalang/teta/pipe";
import { fn, windowFn } from "@tetalang/teta/advanced";
import { toAst } from "@tetalang/teta/inspect";The default entrypoint intentionally excludes compiler-node constructors,
database-specific function builders, and parser-specific AST inspection. Use
@tetalang/teta/advanced for catalog-checked checkedFn(...), explicitly typed
unsafeFn(...), custom fn(...), and windowFn(...) calls,
@tetalang/teta/inspect when you explicitly need the backend parser AST, and
@tetalang/sql directly when building or rendering public IR from another
frontend.
Reusable functional pipelines can be saved with flow(...), while map(...) selects and computes output columns:
import { composeSteps, filterEq, flow, lower, map, pipe, take, whenStep } from "@tetalang/teta";
const activePublicUsers = flow(
filterEq((user) => user.active, true),
map((user) => ({ id: user.id, normalized_email: lower(user.email) })),
whenStep(includeLimit, take(50))
);flow(...) remains the general-purpose function composer. Use
composeSteps(...) when compatible query transforms should become one frozen,
metadata-bearing query step, including when the result will be passed to
whenStep(...) or unlessStep(...):
const activePage = composeSteps(
filterEq((user: typeof users.columns) => user.active, true),
take(50)
);
const usersQuery = pipe(users, whenStep(includeActivePage, activePage));Query values expose columns for typed expression reuse, but internal compiler details such as sources, stages, CTEs, and generated names are intentionally opaque. Use toIR(query) or explain(query, ...) when you need to inspect lowered query structure. Query steps are callable values with lightweight kind and stepName metadata for tooling/debugging.
Query construction is immutable and normalization is handled as a pure compiler pass. Newly allocated query and expression nodes are frozen once, while previously frozen plan structure is shared between derived queries.
Row shapes are constrained to SQL value types, and table schemas must be non-empty objects built from t.* column helpers. Aggregate projections are checked separately from row projections. In fold(...), use group(...) / groupShape(...) for grouping keys and aggregate helpers such as count(...), sum(...), or arrayAgg(...) for aggregate outputs.
Comparison filter helpers require at least one row callback; direct values are allowed only as the other operand.
distinct() removes duplicate rows without changing the query's row type.
whenStep(...) and unlessStep(...) use host-language booleans to include or skip schema-preserving steps while building a query; use filter(...) and predicate expressions for conditions evaluated by SQL.
Use map(...) for explicit object-shaped projections. pick(...), drop(...),
and rename(...) are pure record helpers that compose inside map(...):
import { drop, map, pick, rename, upper } from "@tetalang/teta";
const publicUsers = pipe(
users,
map((user) => ({
id: user.id,
email_upper: upper(user.email),
}))
);Use pick(...) to retain existing fields in a specific order. Pass names either
as variadic arguments or as a key array:
const publicUsers = pipe(users, map(pick("id", "email")));
const sameUsers = pipe(users, map(pick(["id", "email"])));Use drop(...) to remove fields while preserving the order of everything else:
const safeUsers = pipe(
users,
map(drop("password_hash", "recovery_token"))
);Use rename(...) when every field name follows the same mapping rule:
const prefixedUsers = pipe(
users,
map(rename((key) => `user_${key}`))
);Use join(...) with a join-kind specification. The optional second argument to
the specification selects or merges the output columns:
import { eq, join, left } from "@tetalang/teta";
const orders = table("orders", {
order_id: t.int(),
user_id: t.int(),
total: t.float(),
});
const usersWithOrders = pipe(
users,
join(orders, left(
(user, order) => eq(user.id, order.user_id),
(user, order) => ({
user_id: user.id,
order_total: order.total,
})
))
);The available specifications are inner(on, select?), left(on, select?),
right(on, select?), and full(on, select?). Merge helpers such as
dropOverlapRight() can be passed in the same position as a selector.
Predicates involving nullable expressions are typed as Expr<SqlBoolean | null> and are accepted by filter(...), matching SQL's three-valued boolean behavior.
For explicit frontend/backend use, lower the query to IR and render it through @tetalang/sql:
import { irToSql } from "@tetalang/sql";
import { toIR, toSql } from "@tetalang/teta";
const direct = toSql(activeUsers, { dialect: "postgresql" });
const explicit = irToSql(toIR(activeUsers), { dialect: "postgresql" });For a standalone placeholder, provide a runtime SQL type descriptor and pass its value when rendering:
import { eq, filter, param, pipe, t, toSqlResult } from "@tetalang/teta";
const byId = pipe(
users,
filter((user) => eq(user.id, param("id", t.int())))
);
const result = toSqlResult(byId, {
dialect: "postgresql",
params: { id: 42 },
});For positional driver placeholders, use numeric parameter names with array bindings:
const byPosition = pipe(
users,
filter((user) => eq(user.id, param("1", t.int())))
);
const positional = toSqlResult(byPosition, {
dialect: "postgresql",
parameterMode: "positional",
parameterPrefix: "$",
params: [42],
});Prefer prepare(...) for reusable application queries. Its descriptor schema
creates typed parameter expressions, requires exact binding keys, and validates
values before rendering:
import { eq, filter, gte, pipe, prepare, t, toSqlResult } from "@tetalang/teta";
const byUserCriteria = prepare(
{ userId: t.int(), minimumAge: t.int() },
(params) => pipe(
users,
filter((user) => eq(user.id, params.userId)),
filter((user) => gte(user.age, params.minimumAge)),
),
);
const prepared = toSqlResult(byUserCriteria, {
dialect: "postgresql",
params: { userId: 42, minimumAge: 18 },
});Schema descriptors also define decoded result types. Use decodeRow(...) or
decodeRows(...) at the driver boundary when a schema needs to turn raw driver
values into application values.
The callback form gives autocomplete and compile-time column checks while keeping query steps reusable:
import { and, asc, eq, filter, gte, map, sort, pipe } from "@tetalang/teta";
const activeUsers = pipe(
users,
filter((user) => and(eq(user.active, true), gte(user.age, 18))),
map((user) => ({ id: user.id, email: user.email })),
sort((user) => asc(user.email))
);Additional predicate helpers include between(...), isNotIn(...)/notIn(...), and isDistinctFrom(...):
import { and, between, filter, isDistinctFrom, isNotIn } from "@tetalang/teta";
const adultUsers = pipe(
users,
filter((user) => and(
between(user.age, 18, 64),
isNotIn(user.email, ["[email protected]"]),
isDistinctFrom(user.email, "[email protected]"),
))
);More docs:
- Getting Started — first query in 5 minutes
- Tutorial — progressive examples with generated SQL
- Design Philosophy — why function-first, dialect-neutral, immutable
- API Reference — complete typed API with signatures
- Cheatsheet
- Type guide
- Type system
