Kysely com TypeScript e SQL permite escrever consultas com ajuda do compilador sem abandonar o modelo relacional. A biblioteca funciona como um query builder tipado: tabelas e colunas são descritas em interfaces, e as operações geram SQL parametrizado para PostgreSQL, MySQL, SQLite e outros dialetos.
O Kysely não tenta transformar todas as tabelas em objetos com ciclo de vida próprio. Ele mantém joins, agregações, subqueries e transações próximos do SQL. Isso oferece controle, mas exige conhecimento de índices, locks, planos de execução e modelagem. Para revisar os fundamentos, consulte PostgreSQL e o que é TypeScript.
Quando usar Kysely?
Ele é indicado para equipes que desejam consultas explícitas, autocomplete e verificação de nomes e tipos. Também funciona bem em projetos que já possuem um banco modelado e não querem adotar um ORM com unidade de trabalho, lazy loading ou entidades mutáveis.
A tipagem descreve a visão do código sobre o banco. Ela não inspeciona automaticamente cada mudança feita fora do projeto. Migrations e geração de tipos precisam fazer parte do fluxo de desenvolvimento para evitar divergência.
Instalação
npm init -y
npm install kysely pg
npm install --save-dev @types/pg typescriptUse um lockfile e uma versão suportada do Node.js. A URL de conexão deve vir de variável validada ou gerenciador de segredos.
Modelando os tipos
import type {
ColumnType,
Generated,
Insertable,
Selectable,
Updateable
} from 'kysely';
interface UserTable {
id: Generated<string>;
email: string;
name: string;
created_at: ColumnType<Date, Date | string | undefined, never>;
}
interface OrderTable {
id: Generated<string>;
user_id: string;
total_cents: number;
status: 'pending' | 'paid' | 'cancelled';
created_at: ColumnType<Date, Date | string | undefined, never>;
}
interface Database {
users: UserTable;
orders: OrderTable;
}
type User = Selectable<UserTable>;
type NewUser = Insertable<UserTable>;
type UserUpdate = Updateable<UserTable>;Generated indica que a coluna pode ser omitida na inserção. ColumnType permite diferenciar o tipo selecionado, inserido e atualizado. Essas definições não substituem constraints reais no PostgreSQL.
Criando a conexão
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 3000,
idleTimeoutMillis: 30000,
statement_timeout: 5000,
application_name: 'orders-api'
});
export const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool })
});O pool é compartilhado pela instância do Kysely. Não crie uma nova instância por requisição. Em aplicações com múltiplos processos, considere a soma de conexões. Veja pool PostgreSQL no Node.js.
Inserindo registros
const user = await db
.insertInto('users')
.values({
email: input.email.toLowerCase(),
name: input.name
})
.returning(['id', 'email', 'name', 'created_at'])
.executeTakeFirstOrThrow();Selecione explicitamente o retorno para não expor colunas futuras por acidente. Mapeie violações de unique para um erro de conflito e não envie mensagens internas do PostgreSQL ao cliente.
Consultas e filtros
const orders = await db
.selectFrom('orders')
.select(['id', 'total_cents', 'status', 'created_at'])
.where('user_id', '=', userId)
.where(eb =>
cursor
? eb('created_at', '<', cursor)
: eb.val(true)
)
.orderBy('created_at', 'desc')
.orderBy('id', 'desc')
.limit(50)
.execute();Use uma ordenação estável para paginação por cursor. Em tabelas grandes, cursor costuma escalar melhor que OFFSET. O índice deve acompanhar filtro e ordenação, por exemplo (user_id, created_at desc, id desc).
Joins tipados
const order = await db
.selectFrom('orders as o')
.innerJoin('users as u', 'u.id', 'o.user_id')
.select([
'o.id as orderId',
'o.total_cents as totalCents',
'o.status',
'u.name as customerName',
'u.email as customerEmail'
])
.where('o.id', '=', orderId)
.executeTakeFirst();Aliases mantêm o resultado claro. O compilador ajuda a detectar colunas inexistentes e tipos incompatíveis, mas a autorização continua sendo responsabilidade da aplicação. Confirme que o usuário pode acessar o recurso antes de devolver os dados.
Transações
await db.transaction().execute(async trx => {
const order = await trx
.insertInto('orders')
.values({
user_id: userId,
total_cents: totalCents,
status: 'pending'
})
.returning('id')
.executeTakeFirstOrThrow();
await trx
.insertInto('outbox_events')
.values({
aggregate_id: order.id,
event_type: 'order.created',
payload: JSON.stringify({
orderId: order.id,
totalCents
})
})
.execute();
});Mantenha transações curtas. Não faça requisições HTTP ou espere processamento externo enquanto segura conexões e locks. O outbox registra o evento na mesma transação e permite publicá-lo depois.
Atualização concorrente
const result = await db
.updateTable('orders')
.set({ status: 'paid' })
.where('id', '=', orderId)
.where('status', '=', 'pending')
.returning('id')
.executeTakeFirst();
if (!result) {
throw new ConflictError('Order cannot be paid');
}Incluir o estado esperado no WHERE evita sobrescrever uma transição concorrente. Em outros casos, adicione uma coluna version e incremente-a de forma atômica.
SQL avançado
O Kysely fornece o template sql para expressões específicas. Valores interpolados são parametrizados quando usados corretamente.
import { sql } from 'kysely';
const stats = await db
.selectFrom('orders')
.select([
'status',
sql<number>`count(*)::int`.as('total')
])
.where('created_at', '>=', startDate)
.groupBy('status')
.execute();Não use sql.raw com entrada do usuário. Identificadores dinâmicos, ordenação e nomes de tabela devem passar por listas permitidas.
Migrations
O Kysely possui primitivas para migrations, mas o SQL gerado deve ser revisado. Mantenha cada migration pequena e ordenada, execute sob lock e registre quais foram aplicadas.
await db.schema
.createIndex('orders_user_created_idx')
.on('orders')
.columns(['user_id', 'created_at'])
.execute();Alterações grandes em produção podem bloquear tabelas. Teste com volume representativo e use etapas compatíveis. Adicione colunas antes de obrigá-las, preencha dados e só depois imponha NOT NULL.
Validação em runtime
Os tipos TypeScript desaparecem em execução. Valide body, query e parâmetros antes de construir a consulta. O guia de Zod no TypeScript mostra schemas e safeParse.
Mapeie somente campos permitidos. Não passe o objeto recebido diretamente para update, pois isso permite mass assignment e futuras colunas podem ser alteradas sem intenção.
Plugins e logs
Plugins podem transformar queries e observar execução. Use logs de duração e operação, mas remova valores sensíveis. Não registre a URL completa de conexão nem parâmetros pessoais.
Slow queries devem ser analisadas com EXPLAIN ANALYZE. Tipagem não cria índices e não corrige planos ruins.
Segurança
- use credenciais com privilégio mínimo;
- ative TLS conforme a rede;
- parametrize valores;
- restrinja identificadores dinâmicos;
- valide entrada em runtime;
- defina timeouts de conexão e statement;
- revise migrations;
- teste backup e restauração.
Testes de integração
Suba um PostgreSQL isolado, aplique migrations e teste queries reais. Mocks não reproduzem constraints, tipos, transações ou locks. Use rollback por teste ou bancos separados para manter isolamento.
import test from 'node:test';
import assert from 'node:assert/strict';
test('impede transição duplicada', async () => {
const order = await createPendingOrder();
await payOrder(order.id);
await assert.rejects(
() => payOrder(order.id),
error => error.code === 'ORDER_STATE_CONFLICT'
);
});Para regras sem banco, use testes unitários. Consulte testes unitários com Jest.
Observabilidade
Meça duração, erros por código, aquisição de conexão, pool ativo e timeouts. Agrupe queries por operação estável, não pelo SQL completo com valores. Relacione métricas de banco à latência e erros HTTP.
Erros comuns
- Tratar tipos como validação: dados externos continuam desconhecidos;
- Criar db por requisição: pools e conexões se multiplicam;
- Selecionar todas as colunas: contratos e privacidade ficam frágeis;
- Concatenar ordenação: identificadores dinâmicos podem gerar injeção;
- Ignorar plano de execução: uma consulta válida pode ser lenta;
- Migrations destrutivas de uma vez: deploys antigos deixam de funcionar.
Checklist
- interfaces refletem o schema atual;
- constraints existem no banco;
- pool e timeouts estão configurados;
- queries selecionam campos explícitos;
- entrada é validada;
- transações são curtas;
- migrations foram revisadas;
- consultas críticas possuem testes e índices.
Referências oficiais
Conclusão
Kysely com TypeScript e SQL oferece uma camada leve para construir consultas tipadas sem esconder a estrutura relacional. A API melhora autocomplete, refatoração e clareza de resultados.
A qualidade em produção continua dependendo de constraints, validação, índices, transações e migrations seguras. Com esses cuidados, o Kysely funciona como uma ponte eficiente entre o compilador TypeScript e o comportamento real do PostgreSQL.

