Error Handling
Three rules and several patterns.
Rule 1 — Throw typed errors at the boundary
Every @Public method should throw a TitanError (via the Errors
namespace or its subclasses) for client-visible failures. Native
Error becomes an INTERNAL_ERROR (status 500) — your client sees
an opaque server error.
import { Errors } from '@omnitron-dev/titan/errors';
// Wrong
throw new Error('user not found');
// Right
throw Errors.notFound('user', id);
The framework preserves the code, status, and details across the
wire. Clients can instanceof TitanError && e.code === ErrorCode.NOT_FOUND
and act on the typed error.
Rule 2 — Don't catch what you can't handle
// Wrong — catches and silences.
try {
return await this.upstream.fetch();
} catch (e) {
this.logger.error('upstream failed', { e });
return null;
}
The caller now sees null for two different reasons (no data, or
a failure). The upstream failure becomes invisible.
Better:
return await this.upstream.fetch(); // let the error propagate
Or, if you need a fallback:
import { isOperationalError } from '@omnitron-dev/titan/utils';
try {
return await this.upstream.fetch();
} catch (e) {
if (isOperationalError(e)) {
this.logger.warn('upstream unavailable, using cache', { e });
return await this.cache.get();
}
throw e;
}
Catch what you can handle. Re-throw what you can't.
Rule 3 — Failure modes should be explicit
A method that can fail in different ways should signal them with
different ErrorCode values. Callers branch on the code, not on
parsing the message.
import { Errors, ErrorCode, DomainError, defineDomainCodes } from '@omnitron-dev/titan/errors';
const BillingCodes = defineDomainCodes('BILLING', {
INSUFFICIENT_FUNDS: { httpStatus: 409, message: 'Insufficient funds' },
ACCOUNT_FROZEN: { httpStatus: 403, message: 'Account frozen' },
});
@Public()
async transfer(from: string, to: string, amount: number) {
const fromAcct = await this.repo.find(from);
if (!fromAcct) throw Errors.notFound('account', from);
const toAcct = await this.repo.find(to);
if (!toAcct) throw Errors.notFound('account', to);
if (fromAcct.balance < amount) {
throw new DomainError({
code: BillingCodes.INSUFFICIENT_FUNDS,
message: 'Insufficient funds',
details: { available: fromAcct.balance, required: amount },
});
}
if (fromAcct.frozen) {
throw new DomainError({
code: BillingCodes.ACCOUNT_FROZEN,
message: 'Account frozen',
details: { accountId: from },
});
}
return this.repo.transfer(from, to, amount);
}
The client checks e.code (or uses isDomainCode(e, BillingCodes))
to discriminate.
Patterns
Wrap third-party errors
External libraries throw their own error classes. Wrap them at the boundary so the rest of your code (and your clients) sees Titan errors:
import { Errors } from '@omnitron-dev/titan/errors';
async findById(id: string) {
try {
return await this.db.users.findOne({ id });
} catch (e) {
if (e instanceof MongoNetworkError) {
throw Errors.unavailable('database unreachable', { cause: String(e) });
}
throw e; // unknown — let the framework wrap as INTERNAL_ERROR
}
}
Use the cause field
Modern JavaScript errors support cause. Use it to chain failures
without losing the original:
try {
await this.upstream.fetch();
} catch (e) {
throw Errors.unavailable('upstream down', { cause: String(e) });
}
Differentiate validation from business errors
// Schema validation — input is malformed (422).
@Validate(InputSchema) // throws via the framework
// Business validation — input is well-formed but invalid in context (409).
if (input.endDate < input.startDate) {
throw Errors.conflict('end before start', {
startDate: input.startDate,
endDate: input.endDate,
});
}
Both look like "user error" but mean different things.
Centralise domain errors
Per-module error definitions reduce repetition:
// users/errors.ts
import { Errors } from '@omnitron-dev/titan/errors';
export const usersErrors = {
notFound: (id: string) => Errors.notFound('user', id),
emailTaken: (email: string) => Errors.alreadyExists('user', email),
inactive: (id: string) => Errors.conflict('user not active', { id }),
};
Throw from anywhere:
throw usersErrors.notFound(id);
Logging errors
Log errors at the boundary where you decide what to do with them — not at every level of the call stack.
// Service — does not log; throws.
@Public()
async create(input: CreateInput) {
if (await this.repo.findByEmail(input.email)) {
throw Errors.alreadyExists('user', input.email);
}
return this.repo.create(input);
}
// Middleware / interceptor — logs the error and lets it propagate
// to the client.
class ErrorLoggingInterceptor {
async handle(ctx, next) {
try {
return await next();
} catch (e) {
this.logger.error('rpc.error', {
service: ctx.service,
method: ctx.method,
error: e,
});
throw e;
}
}
}
Log once per error. Multiple log lines per error muddy the investigation.
Anti-patterns (consolidated)
- Generic
Error. No code, no status, no structure — becomes a 500. Always use theErrorsnamespace. - Catch-and-swallow. Hides the failure and produces silent wrong-data bugs. Re-throw what you can't handle.
- Catch-and-log-and-rethrow. Doubles the log output and adds no value. Log at the outermost level, throw inside.
- Polymorphic returns.
Promise<User | Error>makes callers type-check the result. Throw the error; let the framework signal failure.
→ Next: Observability.