@vltpkg/error-cause
v0.0.0-0.1730724342581
Published
vlts Error.cause convention
Downloads
413
Keywords
Readme
@vltpkg/error-cause
Utility functions for Error
creation to help enforce vlt's Error.cause
conventions.
Usage · Error Reporting · Conventions · Error Types
Why
Most node programs have a mishmash of error codes and various
Error
subtypes, all in different shapes, making error handling
and reporting more difficult at the top level. This negatively
impacts debugging and user experience.
The JavaScript Error
constructor has a cause
option
which is supported since Node 16.9. We should use it!
This module makes that easy.
Usage
import { error, typeError } from '@vltpkg/error-cause'
// create an error when a lower-level thing fails
try {
doSomethign()
} catch (er) {
throw error('The something for the whatever failed', er)
}
// create an error with some extra information
if (!thing.valid) {
throw error('the thing is not valid', {
code: 'EINVAL',
found: thing,
})
}
// create an error from a lower-level error with extra info
try {
doSomethign(thing)
} catch (er) {
throw error('the thing is not valid', {
code: 'EINVAL',
found: thing,
cause: er,
})
}
// create an error and prune some stack frames
// use this when we want to report the location of a
// function call, not its internals.
const checkBar = () => {
if (!bar) {
// will report from the checkBar() call, not here.
throw error('no bar', { found: bar, wanted: true }, checkBar)
}
// ...
}
The functions will create an error object with a cause
property
if set, and the type checks will ensure that the cause
object
matches vlt's conventions.
Challenges of Error Reporting
- Provide enough information to be useful. On full inspection, we should ideally always get back to not just the initial error that was thrown, but also all locations where the error might have been caught and handled in some way.
- Do not provide more information than is useful. Eg,
console.error(er)
should not fill the entire scrollback buffer. - New modules and libraries should have minimal friction in creating a new style of error when needed. This means, minimize the amount that any module needs to know about the errors raised by any other module, including especially top-level error handling.
- Some information about the error must be known to our top-level error handler, so that it can usefully report errors and suggest corrections.
Solution
- A strictly upheld convention of Error object creation using the
cause
property. - Top level error handler can have special logic where necessary for known error codes, but will still be able to do something more useful when an Error object follows our conventions, even if it's not a code that it knows.
Conventions
The following conventions should be followed for all Error
creation and handling throughout the vlt codebase.
- If you can't help, get out of the way. Just let throws pass through to the top when nothing can be done to assist.
- Add information by using thrown error as
cause
. Use a previously-thrown error as thecause
option. - Add even more info with a double-
cause
. If more info can be added to a prior throw, nest thecause
properties like{ some, other, info, cause: priorError }
. - Always set
cause
, even if no prior error. Use a plain-old JavaScript object following our field conventions. - Rare exception: synthetic ErrnoException style errors. If we are doing something that is similar to a system operation, it's sometimes ok to mimic node's pattern.
- Do not subclass
Error
. Just create a plain old Error, and set thecause
with additional information.
If you can't help, don't get in the way.
Whenever possible, if no remediation or extra information can usefully be added, it's best to just not handle errors and let them be raised at the higher level. For example, instead of this:
let data
try {
data = await readFile(someFile)
} catch (er) {
throw new Error('could not read some file!')
}
this is preferred:
const data = await readFile(someFile)
Add information by using thrown error as cause
.
If we can add information or do something else useful for the
user in understanding the problem, do so by creating a new
Error
and setting the original thrown error as the cause
.
let data
try {
data = await readFile(someFile, 'utf8')
} catch (er) {
// adds semantic information about what the file was for
throw error('The lock file was not found', er)
}
Add even more info with a double-cause
.
If we can add even more information, that should ideally not be
put on the Error we throw, but on a cause
object. Because
cause
objects can nest, we can do something like this:
let data
try {
data = await readFile(someFile, 'utf8')
} catch (er) {
throw error(`could not resolve '${name}'`, {
// extra data about the situation
// it's ok to put big noisy objects in here, not on the error
// object itself!
name,
spec,
target,
// original error that was thrown
cause: er,
})
}
Always set cause
, even if no prior error.
Instead of this:
throw Object.assign(new Error('could not resolve'), {
code: 'ERESOLVE',
from,
spec,
registry,
})
Do this instead:
throw error('could not resolve', {
code: 'ERESOLVE',
from,
spec,
registry,
})
This makes any big objects easily skipped if we want to just
output the error with console.error()
or something, but still
preserves any debugging information that might be useful all the
way down the chain.
Rare exception: synthetic ErrnoException style errors.
In some rare low-level cases, there are operations we perform that are very similar to a node filesystem operation.
For example, the @vltpkg/which
module raises an error that is
intentionally similar to node's filesystem ENOENT
errors,
because that is semantically sensible.
In those cases, the error must follow node's conventions as
close as possible. If we feel the need to add additional
information beyond a known system error code, string path, etc.,
or if the message isn't one that is typically raised by the
underlying system, then it's a good sign that we ought to be
creating an Error
with a cause
so that it can be reported
more usefully.
In such cases, this is fine:
// identical to the error thrown by node's fs
throw Object.assign(new Error('not found'), {
path: someFile,
code: 'ENOENT',
})
But this is way out of bounds and makes no sense:
throw Object.assign(new Error('could not resolve'), {
code: 'EPERM',
spec,
config: someHugeConfigObjectOrSomething,
})
Do not copy properties from a lower-level error or cause onto
the new cause object. That is unnecessary, and obscures the
origin of problems. Instead, just include the lower-level error
as the cause
property. If you already have a low-level error,
you don't need to invent a synthetic one!
For example, do not do this:
let data
try {
data = await readFile(lockFile, 'utf8')
} catch (er) {
throw error('lockfile not found', {
code: er.code,
path: er.path,
})
}
Instead, do this:
let data
try {
data = await readFile(lockFile, 'utf8')
} catch (er) {
throw new Error('lockfile not found', { cause: er })
}
Do not subclass Error
.
Just use the Error
classes defined in the language. Additional
information about error causes should be on the cause
property,
not implicit in the constructor type.
I.e. do not do this:
class VersionError extends Error {
version?: Version
constructor(version: Version | string) {
super('Could not version')
this.version = Version.parse(version)
}
}
// ...
throw new VersionError(myVersion)
Instead, do this:
throw error('Could not version', { version })
cause
Field Conventions
All of these are optional. Additional fields may be used where appropriate, and should be added to this list over time.
cause
- Thecause
field within acause
object should always be anError
object that was previously thrown. Note that thecause
on an Error itself might also be a previously thrown error, if no additional information could be usefully added beyond improving the message.name
- String. The name of something.offset
- Number. The offset in a Buffer or file where we are trying to read or write.registry
- String or URL. A package registry.code
- This must be a string if set, and should only be present if it's one of our creation, not a code raised on a system error. Eg,ERESOLVE
, notENOENT
.path
- The target of a file system operation.target
- path on disk that is being written or extracted tospec
- a@vltpkg/spec.Spec
object relevant to the operation that failed.from
- string. The file path origin of a resolution that failed, for example in the case of relativefile:
specifiers.status
- Number or null. Either the exit code of a process or an HTTP response status code.signal
-NodeJS.Signals
string or null, indicating the signal that terminated a process.validOptions
- Array of valid options when something is not a valid option. (For use indid you mean X?
output.)todo
- String message indicating what bit of work this might be a part of, what feature needs to be implemented, etc. Eg,{ todo: 'nested workspace support' }
.wanted
- A desired value that was not found, or a regular expression or other pattern describing it.found
- The actual value, which was not wanted.max
- A maximum value, which was exceeded.min
- A minimum value, which was not met.response
- An HTTP response or@vltpkg/registry-client.CacheEntry
url
- A string or URL objectrepository
- String git repository remoteversion
- string or@vltpkg/semver.Version
range
- string or@vltpkg/semver.Range
manifest
-@vltpkg/pick-manifest.Manifest
packument
-@vltpkg/pick-manifest.Packument
cwd
- The current working directory of a process that failed
Error Types
- If there is a type problem with an argument, for example a
string
was expected and anumber
was provided, throw aTypeError
. Do not use it for a value that is the correct type but otherwise invalid, such as astring
argument that is actually astring
but does not match an expected pattern. - If the type is fine, but a parsed string is invalid and not
parseable, use
SyntaxError
. - In all other cases, use
Error
.