From mateonunez-skills
Enforces Result<T, E> returns over throws in TypeScript business logic like services, repositories, and domain functions. Uses typed Error subclasses with codes for failure modes.
npx claudepluginhub mateonunez/skillsThis skill uses the workspace's default tool permissions.
> If it doesn't hold up in production, it doesn't make the cut.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Processes PDFs: extracts text/tables/images, merges/splits/rotates pages, adds watermarks, creates/fills forms, encrypts/decrypts, OCRs scans. Activates on PDF mentions or output requests.
Share bugs, ideas, or general feedback.
If it doesn't hold up in production, it doesn't make the cut.
Throwing in business logic hides control flow. Failures become invisible until they propagate to a try/catch three layers up — by which point the type system has nothing useful to say and the caller has no idea what failure modes exist.
In my code, business logic returns Result<T, E>. Throws are reserved for boundaries (Fastify handlers, CLI entry points, framework adapters) where there's no caller to ok/err against.
This is the ait pattern, lifted into a public skill. The canonical implementation lives at @ait/core/types/result.ts.
You are writing or editing one of:
*Service, *Repository, *UseCase)packages/*/src/ that is not a handlerIf it's a Fastify handler, a CLI command, top-level main(), or a test, this skill does not apply — those are boundaries.
// packages/core/src/types/result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E extends Error = Error> = { ok: false; error: E };
export type Result<T, E extends Error = Error> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E extends Error>(error: E): Err<E> => ({ ok: false, error });
E extends Error — the error channel carries a real Error (or subclass), not a plain object. Stack traces still work, cause chaining still works, structured loggers still serialise.
If the codebase already has these helpers, use them:
grep -rE "export (const|function) (ok|err)\b" packages/ src/
In ait they live at @ait/core — same elsewhere.
Result<T, E>, ok, err? Use them.Error subclass for this domain (e.g. AItError)? Use it.code string for each — USER_NOT_FOUND, DB_ERROR, RATE_LIMIT, VALIDATION_ERROR.Errors are Error subclasses with a code field. Not plain objects, not discriminated unions of primitives. The Error base means stack traces, cause chaining, and serialisers all keep working; the code field gives you the discriminator the caller switches on.
// packages/core/src/errors/ait-error.ts
export class AItError extends Error {
readonly code: string;
readonly meta?: Record<string, unknown>;
constructor(
code: string,
message: string,
meta?: Record<string, unknown>,
cause?: unknown,
) {
super(message, { cause });
this.code = code;
this.meta = meta;
this.name = 'AItError';
}
}
Common codes I reuse: USER_NOT_FOUND, DB_ERROR, AUTH_ERROR, CONNECTOR_ERROR, RATE_LIMIT, VALIDATION_ERROR, EMBEDDING_ERROR, RETRIEVAL_ERROR. Pick from the existing catalogue before inventing one.
async function fetchUserData(userId: string): Promise<Result<UserData, AItError>> {
try {
const user = await db.users.findById(userId);
if (!user) return err(new AItError('USER_NOT_FOUND', `User ${userId} not found`));
return ok(user);
} catch (e) {
return err(new AItError('DB_ERROR', 'Failed to fetch user', { userId }, e));
}
}
The outer try/catch here is at the IO seam — that's the right place to convert thrown DB errors into Result. Inside business logic, no try/catch.
async function processUser(userId: string): Promise<Result<ProcessedData, AItError>> {
const userResult = await fetchUserData(userId);
if (!userResult.ok) return userResult; // propagate
const configResult = await getConnectorConfig(userResult.value.configId);
if (!configResult.ok) return configResult; // propagate
return ok(transform(userResult.value, configResult.value));
}
No if/else ladders, no nested try/catch — early-return on error, narrow on ok.
// gateway/src/routes/users.ts
fastify.get('/users/:id', async (request, reply) => {
const r = await fetchUserData(request.params.id);
if (!r.ok) {
switch (r.error.code) {
case 'USER_NOT_FOUND': return reply.code(404).send({ error: r.error.code });
case 'DB_ERROR': return reply.code(503).send({ error: r.error.code });
default: return reply.code(500).send({ error: r.error.code });
}
}
return reply.send(r.value);
});
The boundary is where Result becomes HTTP / process exit / log + crash. Inner code never does that translation.
Result<T, string> or Result<T, { code: string }> — break the E extends Error constraint. Use a real Error subclass.result.value! — non-null assertion on a Result is a bug. Narrow with if (r.ok).try { return ok(await fn()) } catch (e) { return err(e) } — write one safe(fn) helper and reuse it.Result — the signature lies. Caller has no try.UserNotFoundError, DbDownError, RateLimitedError … one AItError (or domain equivalent) with a code string covers it. The code is the discriminant.userService.ts returns Result, every exported function in it does. No half-measures.Result at the boundary just for symmetry — handlers throw or send HTTP; they don't return Result to the framework.Don't migrate unprompted. Note it, propose a follow-up, keep the surrounding style consistent. Mixed-style commits are noise. If I ask for the migration, do it as its own commit with no other changes.
personal/ait/references/core-result-type.mdCONTEXT.md