Typed Errors

Turbine throws typed errors you can catch with instanceof. Every error extends TurbineError and carries a stable readonly code field you can use for programmatic handling. Human-readable .message strings also start with the same code tag (e.g. [TURBINE_E008] …) so logs are greppable without structured field access — but do not parse the message; branch on err.code / instanceof. Postgres driver errors (23505, 23503, 40P01, etc.) are translated into typed classes automatically via wrapPgError().

Error code table

CodeClassWhen thrown
TURBINE_E001NotFoundErrorfindUniqueOrThrow, findFirstOrThrow, update/delete against a non-matching row
TURBINE_E002TimeoutErrorQuery or transaction exceeds its configured timeout
TURBINE_E003ValidationErrorUnknown column, invalid operator, empty-where guard on update/delete
TURBINE_E004ConnectionErrorPool connection failure
TURBINE_E005RelationErrorUnknown relation name in a with clause
TURBINE_E006MigrationErrorMigration file parse error, checksum mismatch, apply failure
TURBINE_E007CircularRelationErrorRelation nesting depth exceeds 10
TURBINE_E008UniqueConstraintErrorpg 23505 — translated via wrapPgError()
TURBINE_E009ForeignKeyErrorpg 23503 — translated via wrapPgError()
TURBINE_E010NotNullViolationErrorpg 23502 — translated via wrapPgError()
TURBINE_E011CheckConstraintErrorpg 23514 — translated via wrapPgError()
TURBINE_E012DeadlockErrorpg 40P01 — retryable, exposes isRetryable: true
TURBINE_E013SerializationFailureErrorpg 40001 — retryable, exposes isRetryable: true
TURBINE_E014PipelineErrorNon-transactional pipeline has partial failures
TURBINE_E015OptimisticLockErrorVersion mismatch on an optimisticLock update — the row was modified by another transaction
TURBINE_E016ExclusionConstraintErrorpg 23P01 — translated via wrapPgError()
TURBINE_E017UnsupportedFeatureErrorA Postgres-only feature (pgvector, LISTEN/NOTIFY, RLS sessionContext) invoked on a non-Postgres engine — exposes feature and dialect

Catching typed errors

Import the classes from the package root and narrow with instanceof.

import {
  NotFoundError,
  ValidationError,
  TimeoutError,
  UniqueConstraintError,
} from 'turbine-orm';
 
try {
  const user = await db.users.findUniqueOrThrow({ where: { id: 999 } });
} catch (err) {
  if (err instanceof NotFoundError) {
    // err.code === 'TURBINE_E001'
    // err.table, err.where, and err.operation are all populated
    return { status: 404 };
  }
  if (err instanceof ValidationError) {
    // err.code === 'TURBINE_E003'
    return { status: 400, message: err.message };
  }
  if (err instanceof TimeoutError) {
    // err.code === 'TURBINE_E002'
    // err.timeoutMs is the configured limit
    return { status: 504 };
  }
  throw err;
}

wrapPgError translation

Every query execution runs its pg error through wrapPgError(). The original driver error is preserved as .cause on the wrapped error.

pg SQLSTATENameTurbine class
23505unique_violationUniqueConstraintError
23503foreign_key_violationForeignKeyError
23502not_null_violationNotNullViolationError
23514check_violationCheckConstraintError
23P01exclusion_violationExclusionConstraintError
40P01deadlock_detectedDeadlockError (retryable)
40001serialization_failureSerializationFailureError (retryable)

Other pg errors pass through unchanged.

import { UniqueConstraintError } from 'turbine-orm';
 
try {
  await db.users.create({ data: { email: 'taken@example.com', ... } });
} catch (err) {
  if (err instanceof UniqueConstraintError) {
    // err.constraint — e.g. 'users_email_key'
    // err.columns   — e.g. ['email']
    // err.table     — e.g. 'users'
    // err.cause     — the original pg error
    return { status: 409, message: 'Email already in use' };
  }
  throw err;
}

Retryable errors

DeadlockError and SerializationFailureError expose a readonly isRetryable = true as const field. This isn't just a comment — it's a type-level signal so your retry loop narrows correctly.

import {
  DeadlockError,
  SerializationFailureError,
} from 'turbine-orm';
 
async function withRetry<T>(
  fn: () => Promise<T>,
  maxAttempts = 3,
): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (
        (err instanceof DeadlockError && err.isRetryable) ||
        (err instanceof SerializationFailureError && err.isRetryable)
      ) {
        // Exponential backoff with jitter
        const backoff = 2 ** attempt * 50 + Math.random() * 25;
        await new Promise((r) => setTimeout(r, backoff));
        continue;
      }
      throw err;
    }
  }
  throw lastErr;
}
 
// Use it on a serializable transaction:
await withRetry(() =>
  db.$transaction(
    async (tx) => {
      const row = await tx.counters.findUniqueOrThrow({ where: { id: 1 } });
      await tx.counters.update({
        where: { id: 1 },
        data: { value: row.value + 1 },
      });
    },
    { isolationLevel: 'Serializable' },
  ),
);

Neither Prisma nor Drizzle surfaces a comparable typed retry signal — Prisma uses the stringly-typed code: 'P2034' on a generic PrismaClientKnownRequestError, and Drizzle bubbles up raw pg errors for you to grep SQL states from.

Pipeline errors

PipelineError is only thrown when a pipeline runs in non-transactional mode ({ transactional: false }) and one or more queries fail. It carries a per-query result array so you can inspect exactly which succeeded and which failed.

import { PipelineError } from 'turbine-orm';
 
try {
  await db.pipeline(queries, { transactional: false });
} catch (err) {
  if (err instanceof PipelineError) {
    // err.results: ({status:'ok', value} | {status:'error', error})[]
    // err.failedIndex: zero-based index of the first failed query
    // err.failedTag: DeferredQuery.tag of the first failure
    for (const [i, slot] of err.results.entries()) {
      if (slot.status === 'error') {
        console.error(`Query ${i} failed:`, slot.error);
      }
    }
  }
}

Transactional pipelines (the default) either fully succeed or roll back — a failure surfaces as whichever typed error the failing query raised.

Unsupported features on other engines

UnsupportedFeatureError (TURBINE_E017) is thrown when you invoke a Postgres-only feature on one of Turbine's other database engines — SQLite, MySQL, or SQL Server. It is not a wrapPgError() translation; Turbine raises it directly (from a capability flag on the active dialect) instead of generating broken SQL. The Postgres-only features are pgvector distance ops, LISTEN/NOTIFY realtime ($listen / $notify), and RLS sessionContext ($withSession).

import { UnsupportedFeatureError } from 'turbine-orm';
import { turbineMysql } from 'turbine-orm/mysql';
 
const db = await turbineMysql(process.env.MYSQL_URL!, SCHEMA);
 
try {
  await db.$listen('orders', (payload) => console.log(payload));
} catch (err) {
  if (err instanceof UnsupportedFeatureError) {
    // err.code    === 'TURBINE_E017'
    // err.feature === 'LISTEN/NOTIFY'
    // err.dialect === 'mysql'
  }
}

On PostgreSQL these features never throw — TURBINE_E017 only appears on the non-Postgres engines. See Database Engines for the full capability matrix.

Safe error messages

By default, NotFoundError messages include only the keys of the where clause — values are redacted so PII doesn't leak into logs (Sentry, Datadog, and friends).

[turbine] findUniqueOrThrow on "users" found no record matching where: { id, email }

The full where object is always available as err.where for programmatic access — only the human-readable message is redacted. Opt into verbose messages (useful in local development) by setting errorMessages: 'verbose' in your TurbineConfig, or by calling setErrorMessageMode('verbose') at startup.

const db = turbine({
  connectionString: process.env.DATABASE_URL,
  errorMessages: process.env.NODE_ENV === 'development' ? 'verbose' : 'safe',
});

See also