Skip to content

Commit

Permalink
Release v1.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
xkcm authored Jul 7, 2023
2 parents 4b1fb44 + 2f1539d commit d6e7c2a
Show file tree
Hide file tree
Showing 20 changed files with 477 additions and 74 deletions.
29 changes: 0 additions & 29 deletions .eslintrc

This file was deleted.

34 changes: 34 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = {
parser: "@typescript-eslint/parser",
plugins: [
"@typescript-eslint"
],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"airbnb/base",
"airbnb-typescript/base"
],
parserOptions: {
ecmaVersion: "latest",
project: "./tsconfig.json"
},
rules: {
quotes: ["error", "double"],
curly: ["error", "all"],
indent: "off",
"no-console": ["error", { allow: ["info", "warn", "error"] }],
"no-use-before-define": ["error", { functions: false, classes: false }],
"brace-style": ["error", "1tbs", { allowSingleLine: false }],
"import/no-unresolved": 0,
"import/prefer-default-export": 0,
"@typescript-eslint/quotes": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/indent": ["error", 2, {
ignoredNodes: ["TSTypeParameterInstantiation"],
SwitchCase: 1,
}]
},
ignorePatterns: [".eslintrc.js"]
}
112 changes: 100 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ A library for expanded error functionality in JavaScript.

Better Errors library provides a `BetterError` class extended from the standard `Error` class. It expands the standard error with **code** and **metadata** properties. The library also contains a couple of utility functions & decorators to define default values to minimize repeated code.

## Installation

You can install the package from NPM registry
```sh
# pnpm
pnpm add @xkcm/better-errors

# yarn
yarn add @xkcm/better-errors

# npm
npm add @xkcm/better-errors
```

## Usage examples

This example contains a simple error class extending from `BetterError` class with a default error code.
Expand All @@ -24,7 +38,7 @@ For details on `BetterError` constructor see the [Reference](#reference) section

---

This example contains setting default value with error's class static method.
This example contains setting default value using a static method from the error class.

```ts
class NotFoundError extends BetterError {}
Expand All @@ -38,7 +52,7 @@ throw new NotFoundErrorWithMessage();

---

This example contains a scenario in which an error with default values and some user data as metadata is thrown.
This example contains a scenario in which an error with default values and some user data is thrown.

```ts
@withCode("auth.user_does_not_exist")
Expand All @@ -53,7 +67,6 @@ async function findUser(userId: string) {
if (!userExists) {
throw new UserDoesNotExistError({ metadata: { userId } });
// ^ type-safe

// throws:
// UserDoesNotExist: User does not exist!
// {
Expand All @@ -66,6 +79,54 @@ async function findUser(userId: string) {

For details on type-safety and decorators see the [Reference](#reference) section.

---

Advanced example with a message template, a getter function for metadata and a specified metadata merging behavior.
```ts
@withMessage("User with id=%{metadata.id} not found (occurred at %{metadata.timestamp})")
@withMetadata(
() => ({
timestamp: new Date(),
}),
"compromise:firm",
)
class UserNotFoundError extends BetterError<{
timestamp: number;
userId?: number;
}> {}

throw new UserNotFoundError({ metadata: { userId: 42 } });
// throws:
// UserNotFoundError: User with id=42 not found (occurred at <timestamp>)
// {
// userId: 42,
// timestamp: <timestamp>
// }
```

## Message template

You can use templates for the error messages. To inject a value to the message use `%{}` symbol.

Example:
```ts
throw new BetterError({
metadata: { userId: 42 },
code: "user_does_not_exist",
message: "User with id=%{metadata.userId} does not exist (E_CODE=%{code})"
});

// throws:
// BetterError: User with id=42 does not exist (E_CODE=user_does_not_exist)
// {
// metadata: { userId: 42 },
// code: "user_does_not_exist"
// }

```

Message template has access to `metadata`, `cause` & `code` properties.

## Reference

#### `BetterError` class
Expand All @@ -84,36 +145,63 @@ For details on type-safety and decorators see the [Reference](#reference) sectio
`Metadata` type comes from the optional generic type. It must extend `Record<string, any>`. This way it's possible to set custom metadata interface and get full IDE autocomplete support. This type is then used in `.withMetadata` static method and `@withMetadata` decorator.

```ts
@withMetadata(/* TypeScript enforces type A here */)
@withMetadata(metadata: A) // TypeScript enforces type A here
class ErrorWithMetadata extends BetterError<A> {}
```

### Decorators and static methods

Decorators and static methods accept both direct values and getter functions which are evaluated on error construction.

Example usage of getter function for metadata and message:

```ts
@withMetadata(() => ({
timestamp: Date.now(),
}))
@withMessage(() => `Error with env=${process.env.ENVIRONMENT}`)
class ErrorWithTimestamp extends BetterError<{ timestamp: number }> {}
```

#### `withMessage`
```ts
// As a decorator
@withMessage(/* string */)
@withMessage(message: string)
class CustomError extends BetterError {}

// As a static method
CustomError.withMessage(/* string */)
CustomError.withMessage(message: string)
```

#### `withCode`
```ts
// As a decorator
@withCode(/* string */)
@withCode(code: string)
class CustomError extends BetterError {}

// As a static method
CustomError.withCode(/* string */)
CustomError.withCode(code: string)
```

#### `withMetadata`
```ts
// As a decorator
@withMetadata(/* Metadata type inferred from the error class */)
class CustomError extends BetterError</* Metadata type here */> {}
@withMetadata(metadata: Metadata)
class CustomError extends BetterError<Metadata> {}

// As a static method
CustomError.withMetadata(/* Metadata type */)
```
CustomError.withMetadata(
metadata: Metadata,
mergingBehavior: "firm" | "submissive" | "compromise:firm" | "compromise:submissive",
)
```

**Merging behavior**

Default metadata (from decorator/static method) and metadata from constructor can be merged in 4 ways:
* `firm` - default metadata takes precedence and the metadata from constructor is discarded
* `submissive` - default metadata is discarded and the metadata from constructor takes precedence if present
* `compromise:firm` - default metadata and the metadata from constructor are recursively merged, if a conflict of object keys occurs then the value from the default metadata takes precedence, arrays get concatenated and objects are merged recursively
* `compromise:submissive` - default metadata and the metadata from constructor are recursively merged, if a conflict of object keys occurs then the value from the metadata from constructor takes precedence, arrays get concatenated and objects are merged recursively

The default merging behavior is `submissive`.
16 changes: 11 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
{
"name": "@xkcm/better-errors",
"version": "1.0.0",
"version": "1.1.0",
"description": "Better errors with TypeScript",
"author": "xkcm",
"license": "ISC",
"repository": {
"url": "https://github.com/xkcm/better-errors"
},
"bugs": {
"url": "https://github.com/xkcm/better-errors/issues"
},
"main": "lib/es5/index.js",
"module": "lib/es6/index.js",
"types": "lib/es6/index.d.ts",
Expand All @@ -11,8 +19,8 @@
],
"scripts": {
"test": "vitest",
"lint": "eslint src/**/* tests/**/* --ext .ts",
"build": "rm -rf ./lib && tsc -p tsconfig.build.json && tsc -p tsconfig.build.es5.json && cp ./src/types.d.ts ./lib/es5/ && cp ./src/types.d.ts ./lib/es6/"
"lint": "eslint ./src ./tests --ext .ts",
"build": "rm -rf ./lib && tsc -p tsconfig.build.es6.json && tsc -p tsconfig.build.es5.json && cp ./src/types.d.ts ./lib/es5/ && cp ./src/types.d.ts ./lib/es6/"
},
"keywords": [
"error",
Expand All @@ -22,8 +30,6 @@
"ts",
"js"
],
"author": "xkcm",
"license": "ISC",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
Expand Down
63 changes: 52 additions & 11 deletions src/core/BetterError.class.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { defineMetadata, getMetadata } from "../utils/metadata.utils";
import { cloneClass } from "../utils/clone.util";
import { resolveGetter } from "../utils/getter.utils";
import { defineMetadata, getMetadata } from "../utils/metadata.utils";
import * as objectUtils from "../utils/object.utils";

import type {
Options,
Getter,
InferMetadata,
MergingBehavior,
Options,
SupportedMetadata,
} from "../types";

Expand All @@ -14,39 +18,76 @@ export default class BetterError<
ErrorClass extends BetterError,
ErrorClassMetadata extends InferMetadata<ErrorClass> = InferMetadata<ErrorClass>,
>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this: typeof BetterError<ErrorClassMetadata> & { new (...args: any[]): ErrorClass },
metadata: ErrorClassMetadata,
metadata: Getter<ErrorClassMetadata>,
mergingBehavior: MergingBehavior = "submissive",
) {
const newClass = cloneClass(this);
defineMetadata("defaults:metadata", metadata, newClass.prototype);
defineMetadata("defaults:metadata", mergingBehavior, newClass.prototype, "mergingBehavior");
return newClass;
}

public static withMessage(message: string) {
public static withMessage(message: Getter<string>) {
const newClass = cloneClass(this);
defineMetadata("defaults:message", message, newClass.prototype);
return newClass;
}

public static withCode(code: string) {
public static withCode(code: Getter<string>) {
const newClass = cloneClass(this);
defineMetadata("defaults:code", code, newClass.prototype);
return newClass;
}

public readonly code: string;
public code: string;

public readonly metadata: Metadata;
public metadata: Metadata;

public constructor(
options?: Options<Metadata>,
) {
super();

this.metadata = options?.metadata ?? getMetadata("defaults:metadata", this);
this.code = options?.code ?? getMetadata("defaults:code", this);
this.message = options?.message ?? getMetadata("defaults:message", this);
this.metadata = this.resolveMetadata(options?.metadata);
this.code = options?.code ?? resolveGetter(
getMetadata<Getter<string>>("defaults:code", this),
);
this.message = options?.message ?? resolveGetter(
getMetadata<Getter<string>>("defaults:message", this),
);
this.cause = options?.cause;

this.parseMessageTemplate();
}

private resolveMetadata(constructorMetadata?: Metadata): Metadata {
const defaultMetadata = resolveGetter(getMetadata<Getter<Metadata>>("defaults:metadata", this));
const mergingBehavior = getMetadata<MergingBehavior>("defaults:metadata", this, "mergingBehavior");

switch (mergingBehavior) {
case "firm":
return defaultMetadata ?? constructorMetadata;
case "compromise:firm":
return objectUtils.mergeRecursively(constructorMetadata ?? {}, defaultMetadata);
case "compromise:submissive":
return objectUtils.mergeRecursively(defaultMetadata, constructorMetadata ?? {});
case "submissive":
default:
return constructorMetadata ?? defaultMetadata;
}
}

private parseMessageTemplate() {
this.message = this.message?.replace(
/%\{(.+?)\}/g,
(placeholder: string) => {
const dataPath = placeholder.slice(2, -1);
const targetObject = objectUtils.pick(this, ["code", "metadata"]);

const value = objectUtils.get(targetObject, dataPath);
return String(value);
},
);
}
}
4 changes: 2 additions & 2 deletions src/decorators/withCode.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import BetterError from "../core/BetterError.class";
import { defineMetadata } from "../utils/metadata.utils";

import type { SupportedMetadata } from "../types";
import type { Getter, SupportedMetadata } from "../types";

export default function withCode(defaultCode: string) {
export default function withCode(defaultCode: Getter<string>) {
return <
M extends SupportedMetadata,
TargetError extends typeof BetterError<M>,
Expand Down
Loading

0 comments on commit d6e7c2a

Please sign in to comment.