Error as value
Apply the errors-as-values convention in TypeScript repositories that use @spotsccc/error-as-value or Error | T return unions. Use when implementing or reviewing typed errors, converting throwing dependencies at boundaries, adapting value errors to exception-based consumers, or handling cancellation and cause chains. Prefer the smallest useful error model. Do not introduce this convention where another error model is established unless the user requests a migration.From its SKILL.md
npx -y skills add spotsccc/error-as-value --skill error-as-valueAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
7.4 KB, ~1.5k tokens by cl100k_base, as published. Nobody here has run it
Error as Value
Use errors as values: functions return Error | T, callers narrow with instanceof Error, and expected failures do not throw.
const user = await getUser(id);
if (user instanceof Error) return user;
return user.name;
Core rules
- Follow the repository's existing error convention. Import only the symbols in use directly from
@spotsccc/error-as-value. - Return expected failures as
Error | T. Throw only at a boundary whose contract requires exceptions or for truly unexpected programmer failures. - Handle errors immediately with an early exit. Keep the success path at the root indentation level.
- Convert exceptions to values once, at the lowest uncontrolled boundary: third-party libraries,
fetch, file I/O, parsing, environment access, or legacy throwing code. - Preserve the original failure with
causewhen wrapping it. - Do not return
unknown | Error; the union collapses tounknown. Parse or type uncontrolled data before returning it. - Use
nullfor an expected absence when the repository distinguishes absence from failure:Error | T | null.
Design errors without overengineering
Default to no new error class.
Before defining an error, identify its current consumer. Create a tagged error only when at least one current requirement needs it:
- a caller branches on this exact failure;
- an HTTP, UI, or job boundary maps it differently;
- recovery or accounting needs structured data from it;
- telemetry needs a stable tag or fingerprint;
- no existing error accurately represents the domain failure.
Otherwise propagate the existing error or reuse the nearest existing domain error.
Do not create an error class only to:
- prepend text to another error;
- copy
cause.messageinto$detail; - represent every individual
.catch()call; - wrap an already suitable domain error at another internal layer;
- prepare for hypothetical future handling.
Every wrapper must add actionable context for a current consumer. Do not wrap the same failure at every layer.
Add a property only when current code reads or serializes it. Search usages before adding fields or a custom constructor. Template variables are for meaningful domain values, not for duplicating the cause message.
Choose the smallest representation
Propagate an existing error
const media = await uploadMedia(input);
if (media instanceof Error) return media;
Define a simple tagged error
Use a class without a constructor when callers need a distinct failure but no extra data.
import { createTaggedError } from '@spotsccc/error-as-value';
class UserLookupError extends createTaggedError({
name: 'UserLookupError',
message: 'Failed to look up user',
}) {}
const user = await lookupUser(id).catch(
(cause) => new UserLookupError({ cause }),
);
The cause already preserves the underlying message and stack. Do not add $detail only to repeat cause.message.
Use template properties for domain context
import { createTaggedError } from '@spotsccc/error-as-value';
class NotFoundError extends createTaggedError({
name: 'NotFoundError',
message: 'User $id not found',
}) {}
return new NotFoundError({ id });
id is justified when it is useful for the message, matching, serialization, or handling.
Add a custom constructor only for required structured data
import { createTaggedError } from '@spotsccc/error-as-value';
class AssetUploadError extends createTaggedError({
name: 'AssetUploadError',
message: 'Failed to upload asset',
}) {
readonly bytesTransferred: number;
constructor(args: { bytesTransferred: number; cause: unknown }) {
super({ cause: args.cause });
this.bytesTransferred = args.bytesTransferred;
}
}
This complexity is justified only if a current caller uses bytesTransferred. Do not move such data into a broader result wrapper unless that representation makes the current flow simpler.
Convert throwing boundaries
For an async boundary, attach .catch() directly to the throwing promise. Reuse an existing error when possible; otherwise create one coarse error for the operation, not one class per technical step.
const response = await fetch(url).catch(
(cause) => new NetworkError({ url, cause }),
);
if (response instanceof Error) return response;
For a synchronous boundary, use tryFn:
import { tryFn } from '@spotsccc/error-as-value';
const config = tryFn(
() => ConfigSchema.parse(JSON.parse(raw)),
(cause) => new ConfigParseError({ cause }),
);
if (config instanceof Error) return config;
Do not add .catch() around internal functions that already return errors as values.
When a boundary only needs a generic failure, a plain Error with cause is valid. A tagged class is not mandatory:
const result = await dependencyCall().catch(
(cause) => new Error('Dependency call failed', { cause }),
);
Handle results
Prefer direct checks and early exits:
const user = await getUser(id);
if (user instanceof Error) return user;
const posts = await getPosts(user.id);
if (posts instanceof Error) return posts;
return render(user, posts);
For parallel operations, check each result before using any success value:
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
if (user instanceof Error) return user;
if (posts instanceof Error) return posts;
return { user, posts };
Log an error only when it is intentionally not propagated. Do not log at every layer because that duplicates reports.
Matching and causes
Use matchError only when several error types genuinely map to different outcomes. Include the Error fallback required for untagged errors.
Use error.findCause(ErrorClass) on errors created by createTaggedError. Use findCause(error, ErrorClass) for any Error. Do not copy cause fields into every wrapper for convenience.
Cancellation
Use isAbortError(error) instead of checking error.name. It walks the cause chain.
If providing a custom abort reason, extend AbortError so wrapped cancellation remains detectable:
import { AbortError, createTaggedError } from '@spotsccc/error-as-value';
class TimeoutError extends createTaggedError({
name: 'TimeoutError',
message: 'Request timed out',
extends: AbortError,
}) {}
Check cancellation before expensive side effects and after awaited calls that receive the signal. Keep abort and generic error checks separate when they have different handling.
Review checklist
- Does each expected failure appear in the return type?
- Is every exception converted at the lowest uncontrolled boundary?
- Can an existing error be propagated instead of wrapped?
- Does every new error class have a current consumer?
- Does every custom property have a current reader or serialization contract?
- Is
causepreserved without copying its message into$detail? - Is the error logged only where it is swallowed or finally handled?
- Can any wrapper, constructor, field, or branch be deleted while preserving behavior?
What ships with it: 1 file
215 B alongside SKILL.md
agents/
- openai.yaml215 B