| name | patterns |
| description | Architectural patterns for code that uses wellcrafted. Covers control flow with trySync/tryAsync, factory function composition, service layers with Result types, error composition across boundaries, and the single-or-array pattern. |
Patterns
A style guide for code that uses wellcrafted. Not API reference — architectural taste.
Human-Readable Control Flow
Mirror natural human reasoning: try the thing, check if it failed, continue on the happy path.
Linearizing try-catch into guards
Before — nested, mixed throw/return:
async function handleRequest(userId: string) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
return Response.json({ user, posts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return Response.json({ error: message }, { status: 500 });
}
}
After — linear guards with tryAsync:
import { tryAsync, Err } from 'wellcrafted/result';
async function handleRequest(userId: string) {
const { data: user, error: userError } = await tryAsync({
try: () => fetchUser(userId),
catch: (cause) => UserError.FetchFailed({ userId, cause }),
});
if (userError) return Response.json({ error: userError.message }, { status: 502 });
const { data: posts, error: postsError } = await tryAsync({
try: () => fetchPosts(user.id),
catch: (cause) => PostError.FetchFailed({ userId: user.id, cause }),
});
if (postsError) return Response.json({ error: postsError.message }, { status: 502 });
return Response.json({ user, posts });
}
Each guard has the same shape: do the thing → check → return early on failure. The happy path accumulates at the bottom.
Natural-language boolean variables
Name booleans so they read like thoughts:
const isAuthenticated = session && !session.expired;
const needsRefresh = token.expiresAt < Date.now() + BUFFER_MS;
const canSkipValidation = input.source === 'trusted' && input.validated;
if (!isAuthenticated) return Response.json({ error: 'Unauthorized' }, { status: 401 });
if (needsRefresh) await refreshToken(token);
Early returns as guard clauses
async function createUser(email: string): Promise<Result<User, UserError>> {
if (!email.includes('@')) return UserError.InvalidEmail({ email });
const existing = await db.findByEmail(email);
if (existing) return UserError.AlreadyExists({ email });
return tryAsync({
try: () => db.users.create({ email }),
catch: (cause) => UserError.CreateFailed({ email, cause }),
});
}
Factory Function Composition
The universal signature
Every factory function follows this shape:
function createSomething(dependencies, options?) {
return { };
}
Two arguments max. First is resources, second is config. Dependencies come first because they're what makes the factory reusable — the same factory with different deps produces different behavior.
function createUserService(db: Database) {
return {
getById(userId: string) { },
create(data: CreateUserInput) { },
};
}
function createNotificationService({ email, sms }: { email: EmailClient; sms: SmsClient }) {
return {
notify(userId: string, message: string) { },
};
}
Separating option layers
Each layer owns its own configuration. Don't mix them.
sendEmail({
timeout: 5000,
retries: 3,
to: 'alice@co.com',
subject: 'Hello',
});
const client = createEmailClient({ timeout: 5000, retries: 3 });
const service = createEmailService(client);
service.send({ to: 'alice@co.com', subject: 'Hello' });
Anti-patterns
function sendEmail(clientOptions: ClientOpts, emailOptions: EmailOpts) {
const client = createClient(clientOptions);
return client.send(emailOptions);
}
const client = createEmailClient(clientOptions);
const service = createEmailService(client);
service.send(emailOptions);
function getUser(db: Database, userId: string) { ... }
function createUser(db: Database, data: UserInput) { ... }
const userService = createUserService(db);
userService.getById(userId);
userService.create(data);
Internal zone ordering
Inside a factory, organize code in four zones:
function createUserService(db: Database, options?: { maxRetries?: number }) {
const maxRetries = options?.maxRetries ?? 3;
let connectionCount = 0;
function withRetry<T>(fn: () => Promise<T>): Promise<T> { }
return {
async getById(userId: string): Promise<Result<User, UserError>> { },
async create(data: CreateUserInput): Promise<Result<User, UserError>> { },
};
}
The return object is always last — it's the complete public API.
Service Layer Pattern
Services are factory functions that return objects with methods returning Result<T, E>. Each service defines its own error vocabulary with defineErrors.
Complete example
import { defineErrors, extractErrorMessage, type InferErrors } from 'wellcrafted/error';
import { Ok, Err, tryAsync, type Result } from 'wellcrafted/result';
const UserError = defineErrors({
NotFound: ({ userId }: { userId: string }) => ({
message: `User ${userId} not found`,
userId,
}),
CreateFailed: ({ email, cause }: { email: string; cause: unknown }) => ({
message: `Failed to create user ${email}: ${extractErrorMessage(cause)}`,
email,
cause,
}),
FetchFailed: ({ cause }: { cause: unknown }) => ({
message: `Failed to fetch users: ${extractErrorMessage(cause)}`,
cause,
}),
});
type UserError = InferErrors<typeof UserError>;
function createUserService(db: Database) {
return {
async getById(userId: string): Promise<Result<User, UserError>> {
const { data: user, error } = await tryAsync({
try: () => db.users.findById(userId),
catch: (cause) => UserError.FetchFailed({ cause }),
});
if (error) return Err(error);
if (!user) return UserError.NotFound({ userId });
return Ok(user);
},
async create(email: string): Promise<Result<User, UserError>> {
return tryAsync({
try: () => db.users.insert({ email }),
catch: (cause) => UserError.CreateFailed({ email, cause }),
});
},
};
}
type UserService = ReturnType<typeof createUserService>;
const UserServiceLive = createUserService(productionDb);
The factory is for testing (inject mocks), the Live instance is for production.
Namespace re-exports
Organize services hierarchically:
import { UserServiceLive } from './user';
import { PostServiceLive } from './post';
export const services = {
users: UserServiceLive,
posts: PostServiceLive,
} as const;
Error Composition Across Layers
Each layer defines its own error vocabulary. Inner errors become cause fields in higher-level errors. extractErrorMessage formats them inside the factory.
const HttpError = defineErrors({
Connection: ({ url, cause }: { url: string; cause: unknown }) => ({
message: `Failed to connect to ${url}: ${extractErrorMessage(cause)}`,
url,
cause,
}),
Response: ({ url, status }: { url: string; status: number }) => ({
message: `${url} returned HTTP ${status}`,
url,
status,
}),
});
const UserError = defineErrors({
NotFound: ({ userId }: { userId: string }) => ({
message: `User ${userId} not found`,
userId,
}),
FetchFailed: ({ userId, cause }: { userId: string; cause: unknown }) => ({
message: `Failed to fetch user ${userId}: ${extractErrorMessage(cause)}`,
userId,
cause,
}),
});
type UserError = InferErrors<typeof UserError>;
async function getUser(userId: string): Promise<Result<User, UserError>> {
const { data: response, error } = await tryAsync({
try: () => fetch(`/api/users/${userId}`),
catch: (cause) => UserError.FetchFailed({ userId, cause }),
});
if (error) return Err(error);
if (response.status === 404) return UserError.NotFound({ userId });
return tryAsync({
try: () => response.json() as Promise<User>,
catch: (cause) => UserError.FetchFailed({ userId, cause }),
});
}
The full error chain is JSON-serializable at every level. Log it, send it over the wire, display it in a toast.
See also: define-errors skill for error variant definitions. result-types skill for trySync/tryAsync patterns.
The Single-or-Array Pattern
Accept both single items and arrays, normalize at the top, process uniformly.
function deleteUsers(userOrUsers: User | User[]): Promise<Result<void, DbError>> {
const users = Array.isArray(userOrUsers) ? userOrUsers : [userOrUsers];
const ids = users.map((u) => u.id);
return tryAsync({
try: () => db.users.bulkDelete(ids),
catch: (cause) => DbError.DeleteFailed({ cause }),
});
}
await deleteUsers(user);
await deleteUsers([user1, user2, user3]);
Naming convention
| Parameter | Normalized Variable |
|---|
userOrUsers | users |
itemOrItems | items |
postOrPosts | posts |
Anti-patterns
function deleteUser(user: User): Promise<...>;
function deleteUsers(users: User[]): Promise<...>;
deleteUsers([user]);
function deleteUser(user: User) { return db.delete(user.id); }
function deleteUsers(users: User[]) { return db.bulkDelete(users.map(u => u.id)); }
function deleteUsers(userOrUsers: User | User[]) {
const users = Array.isArray(userOrUsers) ? userOrUsers : [userOrUsers];
return db.bulkDelete(users.map((u) => u.id));
}