| name | result-types |
| description | Working with Result types, Ok, Err, trySync, tryAsync, and utility functions from wellcrafted. Use when wrapping unsafe code, handling errors with Results, or destructuring { data, error } responses. |
Result Types
import { Ok, Err, trySync, tryAsync, type Result } from 'wellcrafted/result';
The Shape
Results are plain objects with two properties — data and error. Successful results carry a T in data with error: null; failed results carry an E in error with data: null.
type Ok<T> = { data: T; error: null };
type Err<E> = { error: E; data: null };
type Result<T, E> = Ok<T> | Err<E>;
This is the same destructuring shape used by Supabase and SvelteKit load functions. Always discriminate by the error side — isErr(result) or result.error !== null:
const { data, error } = await someOperation();
if (error !== null) {
return;
}
Never discriminate by data
Ok(null) is a legitimate value (T can be null — common for "not found is not an error"), so data === null is ambiguous: it could be Ok<null> or Err<E>. The only reliable discriminator is the error side.
if (result.data === null) { }
if (result.error !== null) { }
if (isErr(result)) { }
Don't call Err(null). It produces { data: null, error: null } — structurally identical to Ok(null). Under the shape, isErr/isOk read it as Ok, so Err(null) silently becomes success. Err(undefined) is also discouraged — the discriminator technically works (undefined !== null is true), but the value is meaningless and trips if (error) falsy checks downstream. Either:
- Use
Ok(null)/Ok(undefined) (if what you meant was success-with-no-payload).
- Define a tagged error via
defineErrors with a real name.
- Wrap a caught exception in one of your
defineErrors variants, e.g. MyError.Unexpected({ cause: error }) (see below). There is no TaggedError factory to import; you create the namespace yourself with defineErrors.
At every catch (error: unknown) boundary, don't pass the raw unknown to Err. Wrap it in a tagged error via defineErrors. The tagged error is non-null by construction, so the shape's invariant holds regardless of what was thrown (including throw null). See docs/philosophy/err-null-is-ok-null.md for why this is a documentation rule rather than a type-level constraint.
Constructors
const result = Ok({ id: '123', name: 'Alice' });
const result = Err({ name: 'NotFound', message: 'User not found' });
const result = Ok(undefined);
trySync and tryAsync
Wrap throwing operations into Results. The catch handler receives the raw error and returns an error variant.
import { defineErrors, extractErrorMessage } from 'wellcrafted/error';
const JsonError = defineErrors({
ParseFailed: ({ input, cause }: { input: string; cause: unknown }) => ({
message: `Invalid JSON: ${extractErrorMessage(cause)}`,
input: input.slice(0, 100),
cause,
}),
});
const { data, error } = trySync({
try: () => JSON.parse(rawInput),
catch: (cause) => JsonError.ParseFailed({ input: rawInput, cause }),
});
const { data, error } = await tryAsync({
try: () => fetch(url).then((r) => r.json()),
catch: (cause) => HttpError.Connection({ url, cause }),
});
See also: define-errors skill for creating error variants.
Key Rules
- Use
trySync for synchronous code, tryAsync for async
- Always
await tryAsync — it returns a Promise
- Match return types — if try returns
T, catch should return Err<E> or Ok<T> for recovery
- Use
Ok(undefined) for void operations
- Return
Err(error) to propagate errors up the chain
- Pass the raw caught error as
cause — let the error factory call extractErrorMessage
Recovery Pattern
When catch returns Ok(fallback) instead of Err, the return type narrows to Ok<T> — no error checking needed:
const { data: config } = trySync({
try: (): unknown => JSON.parse(configJson),
catch: () => Ok({ theme: 'dark', fontSize: 14 }),
});
const { data: exists } = trySync({
try: () => fs.existsSync(path),
catch: () => Ok(false),
});
Wrapping Guidelines
Minimal wrap — only the risky operation
const { data: response, error } = await tryAsync({
try: () => fetch(`/api/users/${userId}`),
catch: (cause) => UserError.FetchFailed({ userId, cause }),
});
if (error !== null) return Err(error);
const user = await response.json();
return Ok(user);
const { data, error } = await tryAsync({
try: async () => {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
await updateCache(user);
return user;
},
catch: (error) => Err(error),
});
Immediate return pattern
Return errors immediately after checking. This creates linear control flow.
const { data: user, error: fetchError } = await getUser(userId);
if (fetchError) return Err(fetchError);
const { data: posts, error: postsError } = await getPosts(user.id);
if (postsError) return Err(postsError);
return Ok({ user, posts });
const { data: user, error: fetchError } = await getUser(userId);
if (!fetchError) {
const { data: posts, error: postsError } = await getPosts(user.id);
if (!postsError) {
return Ok({ user, posts });
} else {
return Err(postsError);
}
} else {
return Err(fetchError);
}
When to extend the try block
Include multiple operations in one block when they must succeed or fail together:
const { data, error } = await tryAsync({
try: async () => {
const validated = schema.parse(document);
const saved = await db.documents.insert(validated);
await index.add(saved.id, saved.content);
return saved;
},
catch: (cause) => DbError.InsertFailed({ cause }),
});
The Destructured-Error Gotcha
When you destructure { data, error }, the error variable is the raw error value — NOT wrapped in Err. You must wrap it before returning from a function that returns Result:
const { data, error } = await tryAsync({ ... });
if (error !== null) return error;
const { data, error } = await tryAsync({ ... });
if (error !== null) return Err(error);
This is different from returning the entire result object:
const result = await tryAsync({ ... });
if (result.error !== null) return result;
Utility Functions
isOk / isErr — type guards
import { isOk, isErr } from 'wellcrafted/result';
const result = await getUser(userId);
if (isOk(result)) {
console.log(result.data.name);
}
if (isErr(result)) {
console.log(result.error.message);
}
unwrap — extract data or throw
import { unwrap } from 'wellcrafted/result';
const user = unwrap(await getUser(userId));
Use sparingly — unwrap throws, which defeats the purpose of Result types. Useful in tests and scripts where you know the operation should succeed.
resolve — handle values that may or may not be Results
import { resolve } from 'wellcrafted/result';
const data = resolve(maybeResult);
partitionResults — split an array of Results
import { partitionResults } from 'wellcrafted/result';
const results = await Promise.all(userIds.map(getUser));
const { oks, errs } = partitionResults(results);
The arrays hold the Result objects themselves, not the unwrapped values. Read .data off each Ok and .error off each Err.
Wrapping Summary
| Scenario | Approach |
|---|
| Single risky operation | Wrap just that operation |
| Sequential operations | Wrap each separately, return immediately on error |
| Atomic operations | Wrap together in one block |
| Different error types | Separate blocks with appropriate error types |
See also: define-errors skill for error variant definitions. patterns skill for service architecture.