Graou
🐺 What is Graou?
Graou is a lightweight TypeScript library that aims to provide a clean, structured, and reusable approach to creating and managing application errors instead of handling exceptions in an inconsistent way throughout the codebase.
Note
Check our examples to see our way to use it 😀 examples
How to contributes
About the wiki
- File naming:
The files must be prefix by a two digits number
then the page title with a
snake_caseformat. - Create pages:
Create a file matching first rule and add it to the
SUMMARY.md. The file is ignored if not linked in the summary. - Create a section:
Create a directory matching first rule.
Create a
00_readme.mdor00_intro.mdand add it to the summary. Reason: The summary wont allow non link entry. - Media:
If your page contains pictures you must put it inside a folder
with the same filename than the markdown file.
The folder name didn’t contains the
.mdextension. Example:src/01_chapter/01_page.mdhas a media insidesrc/01_chapter/01_page/my_media.png. The markdown refer it with.
Core Concept
Create errors
One of the first features of Graou is the error factory, which removes a lot of hassle from error management. In a usual project, the good practice would be to create an error for each use case. If you’re like me, you tend to create a single error and use it everywhere because it’s boring to maintain. Graou removes this issue by providing a factory for errors.
Note
Step by step
The first step is to instantiate a factory for your package. The key information is that errors are basically module-scoped. An error factory instance mustn’t be used or exported outside your library/app. You will be able to customize some options for the errors.
import graou from "@carthage-js/graou";
const errorsFactory = graou.makeModuleErrorsFactory({
moduleName: "<YOUR PROJECT NAME>",
});
You can also provide a factory for the message held by the native error on makeModuleErrorsFactory with messageFactory.
The library provides a default pattern, but you can customize it if you need to.
const errorsFactory = graou.makeModuleErrorsFactory({
moduleName: "<YOUR PROJECT NAME>",
messageFactory: (
nodeModule: string,
scope: string,
code: string,
subcode: string | null,
reason: string,
) => "My custom message",
});
With this factory, you will be able to create errors for any case you need. In the library design, you should create a bunch of errors by class or standalone functions. To demonstrate this design, we are going to use this code:
class MyToDoClient {
private _baseUrl: string;
constructor(baseUrl: string) {
this._baseUrl = baseUrl;
}
async getVersion(): Promise<string> {
const response = await fetch(`${this._baseUrl}/version`);
const body = await response.json();
return body.version;
}
async getToDoList(id: number): Promise<string> {
const response = await fetch(`${this._baseUrl}/todo_list/${id}`);
return await response.json();
}
}
It’s a simple REST API client, but this example doesn’t handle errors. So a 5xx or 4xx status code is not handled. By design, we want to handle this kind of thing, so we add this code to the method:
const response = await fetch(`${this._baseUrl}/...`);
if (response.status < 200 || response.status >= 300) {
throw new Error("Something went wrong", { cause: response });
}
const body = await response.json();
return body.version;
It’s actually nice because now we know when a request fails.
It’s not really good practice to use the basic error directly.
The developer won’t be able to distinguish errors in the catch if they are using the same type.
So let’s go ahead and add a custom error class:
class MyToDoClientError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
We can now replace new Error with new MyToDoClientError.
So now, we can distinguish errors issued by the client from the rest of the code.
It’s actually what libraries like Axios (HTTP client) do.
There is still an issue here. We can’t distinguish which method was called here.
If we repeat what we did earlier, it’s pretty easy to handle.
class MyToDoClientError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
class MyToDoClientGetVersionError extends MyToDoClientError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
class MyToDoClientGetToDoListError extends MyToDoClientError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
Then we will be able to throw the error where we need it. It’s starting to feel a bit redundant. So we end up keeping a single error class because it’s easier and quicker to handle. Graou removes this hassle with the factory we created earlier.
const MyToDoClientErrors = errorsFactory("MyToDoClient", ["GetVersion", "GetToDoList"]);
Now we can replace our MyToDoClientError by MyToDoClientErrors.codes.<GetVersion|GetToDoList>.factory.
Our file will look like this:
const MyToDoClientErrors = errorsFactory("MyToDoClient", ["GetVersion", "GetToDoList"]);
class MyToDoClient {
private _baseUrl: string;
constructor(baseUrl: string) {
this._baseUrl = baseUrl;
}
async getVersion(): Promise<string> {
const response = await fetch(`${this._baseUrl}/version`);
if (response.status < 200 || response.status >= 300) {
throw new MyToDoClientErrors.codes.GetVersion.factory("Something went wrong", {
cause: response,
});
}
const body = await response.json();
return body.version;
}
async getToDoList(id: number): Promise<string> {
const response = await fetch(`${this._baseUrl}/todo_list/${id}`);
if (response.status < 200 || response.status >= 300) {
throw new MyToDoClientErrors.codes.GetToDoList.factory("Something went wrong", {
cause: response,
});
}
return await response.json();
}
}
On usage, we can do things like this:
const client = new MyToDoClient("https://my.todo.mock");
try {
// ... things with my client
} catch (err) {
if (err instanceof MyToDoClientErrors.codes.GetVersion.$class) {
// It's GetVersion that throw an error
} else if (err instanceof MyToDoClientErrors.scope.$class) {
// It's a thing related to my client.
}
}
Note
The reason parameter of the error factory become optional.
Logging
Another good thing that comes with Graou errors.
They have a toJson method that helps either send data back to the client or print it in logs.
This is particularly useful when working with Datadog, Grafana, or other logging systems.
import graou from "@carthage-js/graou";
try {
// ... Critical code
} catch (err) {
if (err instanceof graou.GraouError) {
console.error(err.toJson());
}
}
So the library will print this kind of JSON:
{
"nodeModule": "my_node_module",
"scope": "MyToDoClient",
"code": "GetVersion",
"reason": "Something went wrong",
"cause": "Raw error message"
}
The resulting JSON sums up the data given to the factory with the data given to the factory method.
The cause attribute is either the same structured JSON if it’s a GraouError, or only the message if it’s a basic Error.
The cause is not disclosed if it’s something else, to avoid printing sensitive data.
You can also control how deep you want to stringify this error with the depth parameter of the method.
This way, it won’t show the cause because it can’t go any deeper.
Subcodes
In some cases, you need to provide more details about a method because a single error may not be sufficient. You can create subcodes for a code. A subcode works like a regular code, but it directly inherits from the code class rather than from the scope class.
const errors = errorsFactory("MyScope", ["SimpleCode", "CodeWithSubcode"], {
CodeWithSubcode: ["CodeA", "CodeB"],
});
// usage
throw errors.codes.CodeWithSubcode.subcodes.CodeA.factory("Hello World !");
Note
The code is no longer abstract when you define subcodes. The whole thing has been rework to play nicely with the error handling and auto errors features.
Class relationship diagram
---
title: Our errors created by the factory
---
classDiagram
class Error {
+string name
+string message
+string stack
+any cause
Error(message: string, options?: ErrorOptions)
}
namespace graou {
class GraouError {
+nodeModule: string
+scope: string
+code: string
+subcode: string|null
+reason: string
+GraouError(nodeModule: string, scope: string, code: string, subcode: string|null, reason: string, fullMessage: string, options?: ErrorOptions)
}
}
Error <|-- GraouError
namespace yourProject {
class ScopeError {
+ScopeError(code: string, subcode: string|null, reason: string, options?: ErrorOptions)
}
class CodeAError {
+CodeAError(reason: string, options?: ErrorOptions)
}
class CodeBError {
+CodeBError(subcode: string|null, reason: string, options?: ErrorOptions)
}
class CodeBSubcodeAError {
+CodeBSubcodeAError(reason: string, options?: ErrorOptions)
}
class CodeBSubcodeBError {
+CodeBSubcodeBError(reason: string, options?: ErrorOptions)
}
class CodeCError {
+CodeCError(reason: string, options?: ErrorOptions)
}
}
GraouError <|-- ScopeError
ScopeError <|-- CodeAError
ScopeError <|-- CodeBError
ScopeError <|-- CodeCError
CodeBError <|-- CodeBSubcodeAError
CodeBError <|-- CodeBSubcodeBError
Handle errors
An error created using a factory comes with a set of helper methods to handle different cases throughout the codebase. These methods are not available on a code error when the code has subcodes. However, they are available on errors created from those subcodes.
Note
Check out those examples:
with
This method creates a copy of the code helper with options that affect the behavior of its methods.
reason
You can define a default reason if the factory is called with a null reason.
const errors = errorsFactory("MyScope", ["Code"]);
throw errors.codes.Code.with({ reason: "My reason" }).factory(); // Error will use 'My reason' as error reason.
throw errors.codes.Code.with({ reason: "My reason" }).factory("A reason"); // Error will use 'A reason' as error reason.
uid
Note
Field has been rename from symbol to uid. It’s also no longer a symbol to make it work with lookup feature.
You can define a uid to flag an error and avoid decorate something that you think the base error is suffcient.
const errors = errorsFactory("MyScope", ["CodeA", "CodeB"]);
const uid = "my use case";
throw errors.codes.CodeA.with({ uid }).factory(null, {
cause: errors.codes.CodeB.with({ uid }).factory(),
}); // Will result into only a CodeB error
We got two major use case that can beneficiate of this feature:
- Promise: without the symbol all the catch will decorate the previous throwing catch.
const errors = errorsFactory("MyScope", ["CodeA", "CodeB", "CodeC"]);
const uid = "my use case";
const promise = (async () => {
// ... Critical code
})();
promise
.catch(errors.codes.CodeA.with({ uid }).$throw)
.then(() => {
// ... Critical code
})
.catch(errors.codes.CodeB.with({ uid }).$throw)
.then(() => {
// ... Critical code
})
.catch(errors.codes.CodeC.with({ uid }).$throw);
// Throwing will result into either CodeA, CodeB, CodeC where cause is not a CodeA, CodeB, CodeC error
- Recusive function: without the symbol each call will decorate the error
const errors = errorsFactory("MyScope", ["Code"]);
const uid = "my use case";
function recurse(steps: number) {
errors.codes.Code.with({ uid }).trap(() => {
if (steps >= 0) {
recurse(steps - 1);
} else {
throw new Error("Oupsie");
}
});
}
// Throwing will result into a single Code error rather than a Code with [steps - 1] Code cause.
Special case with subcodes
The whole design about using subcode is about to make thing clear about a peculiar case. This way, you decorate only errors that you didn’t expect or didn’t want to manage their. This whole idea is to hydrate information on an error than remove it. So in this case, the subcode is not decorated with the code error.
decorate / $throw
A method that decorate an error with the code error.
$throw automaticly rethrow the error.
Here some exemples of how to use them:
const errors = errorsFactory("MyScope", ["Code"]);
try {
// ... Something critical
} catch (err) {
throw errors.codes.Code.decorate(err);
}
// $throw is usefull to work with promise
// $throw has template parameter to avoid lost typing of the promise.
Promise.resolve(10).catch(errors.codes.Code.$throw<number>);
trap
This function captures errors thrown by both synchronous and asynchronous functions. It uses both of the previously mentioned methods to decorate the error.
const errors = errorsFactory("MyScope", ["Code"]);
const result = errors.codes.Code.trap(() => {
// ... Critical things
return "my result";
});
lookup
This function is able to quickly query nested error no matter how big is your error.
const errors = errorsFactory("MyScope", ["Code", "Code2"]);
const errExample = errors.codes.Code.factory(null, {
cause: errors.codes.Code2.factory(),
});
// usage
const result = errors.codes.Code2.lookup(errExample);
const result = errors.codes.Code2.lookup(() => {
// Same result than before but it's pretty neat on test unit.
throw errExample;
});
You can use the uid property to lookup a specific instance when you got twice the same error type.
const errors = errorsFactory("MyScope", ["Code"]);
const errExample = errors.codes.Code.with({ uid: "Code1" }).factory(null, {
cause: errors.codes.Code.with({ uid: "Code2" }).factory(),
});
// usage
const result = errors.codes.Code.lookup(errExample); // root error
const result = errors.codes.Code.lookup(errExample, "Code1"); // root error
const result = errors.codes.Code2.lookup(errExample, "Code2"); // nested error
const result = errors.codes.Code2.lookup(errExample, "Code3"); // undefined because no error exist with this id
Auto errors
Auto class errors
Note
Check out the example: TS
Graou can edit your class to automaticly implements the whole concepts without the need to do much things.
Important
Only the owned methods of the class are decorated. Inherit methods are not altered.
import graou from "@carthage-js/graou";
const errorsFactory = graou.makeModuleErrorsFactory({
moduleName: "<YOUR PROJECT NAME>",
});
class MyClass {
myMethod() {
// ... my code
}
async myAsyncMethod() {
// ... my code
}
}
const MyClassDecorated = graou.utils.decorateClassWithErrors(errorsFactory, MyClass);
// Usage
const instance = new MyClassDecorated();
instance.myMethod();
In this example, the resulting class is attach to Errors
where the scope is MyClass with 3 codes: constructor, myMethod, myAsyncMethod.
Each code matching a method of the class.
You got a constructor code no matter what because they are implicitly declarated by the js class.
Both methods and constructor on the decorated use the trap mecanism to handle errors.
The decorated class define a symbol for each method to handle recursive method.
Graou offer methods to easily access errors that got attached to the class and methods:
const errors = graou.utils.getClassErrors(MyClassDecorated);
const error = graou.utils.getMethodError(MyClassDecorated.prototype.myMethod);
If you allow yourself to use experimentalDecorators on your typescript project then it’s even easier:
import graou from "@carthage-js/graou";
const errorsFactory = graou.makeModuleErrorsFactory({
moduleName: "<YOUR PROJECT NAME>",
});
@graou.decorators.AutoErrors(errorsFactory)
class MyClass {
myMethod() {
// ... my code
}
async myAsyncMethod() {
// ... my code
}
}
// Usage
const instance = new MyClass();
instance.myMethod();
Note
You can pass directly an errors that match your class because it can be much easier for the typing over the usage. You must aware that errors object must match your class declaration. Otherwise, Graou throw an error due to the mismatch between the two objects.
Bind class errors
Note
Check out the example: TS
Graou offer also an alternate way to alterate your class.
The whole reason is due to typing.
Make the errors fully generated mean that you must rely upon getClassErrors and getMethodError function to access the basic concepts of Graou for those class.
There are not bad but they can create some dumb error because the things are solved during runtime.
To avoid that, you can define separetly the class and errors.
This way typescript will be able to ensure the typing.
Other tools will also be able to issue early error if something is undefined (like webpack).
Graou is gonna check the class and errors are matching when he decorate the class with them to avoid issues.
import graou from "@carthage-js/graou";
const errorsFactory = graou.makeModuleErrorsFactory({
moduleName: "<YOUR PROJECT NAME>",
});
const errors = errorsFactory("MyClass", ["myMethod", "myAsyncMethod"]);
class MyClass {
myMethod() {
// ... my code
}
async myAsyncMethod() {
// ... my code
}
}
const MyClassDecorated = graou.utils.bindClassWithErrors(errors, MyClass);
// Usage
const instance = new MyClassDecorated();
instance.myMethod();