$ npm install @vltpkg/error-causeUtility functions for Error creation to help enforce vlt's
Error.cause conventions.
Usage · Error Reporting · Conventions · Error Types
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.
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.
console.error(er) should not fill the entire scrollback buffer.cause property.The following conventions should be followed for all Error creation
and handling throughout the vlt codebase.
cause. Use a
previously-thrown error as the cause option.cause. If more info can be
added to a prior throw, nest the cause properties like
{ some, other, info, cause: priorError }.cause, even if no prior error. Use a plain-old
JavaScript object following our field conventions.Error. Just create a plain old Error, and set
the cause with additional information.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)
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)
}
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,
})
}
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.
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 })
}
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 ConventionsAll of these are optional. Additional fields may be used where appropriate, and should be added to this list over time.
cause - The cause field within a cause object should always be
an Error object that was previously thrown. Note that the cause
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, not ENOENT.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 relative file: 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 in did 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.CacheEntryurl - A string or URL objectrepository - String git repository remoteversion - string or @vltpkg/semver.Versionrange - string or @vltpkg/semver.Rangemanifest - @vltpkg/pick-manifest.Manifestpackument - @vltpkg/pick-manifest.Packumentcwd - The current working directory of a process that failedstring was expected and a number was provided, throw a
TypeError. Do not use it for a value that is the correct type
but otherwise invalid, such as a string argument that is actually
a string but does not match an expected pattern.SyntaxError.Error.