sweet-decorators
TypeScript icon, indicating that this package has built-in type declarations

1.9.0 • Public • Published

🍬 Sweet Decorators Lib

license MIT npm GitHub last commit

Zero dependency collection of common used typescript & javascript patterns provided in convenient format of decorators.

Why use this lib?

  1. It can make your code more concise
  2. It is written in TypeScript and covered with tests
  3. It has 0 dependencies
  4. It is modular and tree-shakable - you don't have to use and ship unused parts

Why not

  1. Decorators proposal is not standardized
  2. TypeScript uses legacy version of it

Before You Begin

⚠️ Please, consider to read Microsoft's article about Decorators Composition ⚠️

📦 Installation (npm)

npm i --save sweet-decorators

📦 Installation (yarn)

yarn add sweet-decorators

📕 Table of contents

👀 Demo

Code demo

@Mixin

Mixin is a pattern of assigning new methods and static properties to an existing class. It's called composition. This decorator just makes it easy, by using by abstracting applyMixin function described in typescript docs

Example

import { Mixin } from "sweet-decorators";

class Swimmable {
  swim() {
    console.log("🏊‍♂️");
  }
}

class Walkable {
  walk() {
    console.log("🚶‍♂️");
  }
}

class Flyable {
  fly() {
    console.log("🐦");
  }
}

@Mixin(Swimmable, Walkable)
class Human {}
interface Human extends Swimmable, Walkable {}

@Mixin(Walkable, Flyable)
class Bird {}
interface Bird extends Walkable, Flyable {}

const human = new Human();
human.swim();
// => 🏊‍♂️

const bird = new Bird();
bird.fly();
// => 🐦

Meta assignment via @Assign and @Assign.<Key>

This decorator is used in pair with readMeta method. Here is the example:

import { Assign, readMeta } from "sweet-decorators";

// You can use multiple syntaxes of the decorator
@Assign({ BASE_BEHAVIOR: "passive" }) // If u pass object, his props will be merged with current meta
@Assign("isTest", process.env.NODE_ENV === "test") // You can set prop directly
@Assign.ErrorClass(Error) // Or you can use @Assign.<key>(<value>) because its more beautiful alt to @Assign('<key>', <value>)
class Human {
  // All these decorators also applies to methods
  @Assign({ IS_ACTION: true })
  @Assign.ActionType("MOVEMENT")
  @Assign(Symbol("ENTITY_TOKEN"), "X_HUMAN_ENTITY")
  @Assign({ IS_ACTION: false }) // will be overridden
  walk() {}
}

class Child extends Human {
  @Assign({ isClumsy: true })
  walk() {}
}

@Assign({ isFavorite: true })
class FavoriteChild extends Human {

}

const human = new Human();
console.log(readMeta(human));
// => { BASE_BEHAVIOR: "passive", isTest: false, ErrorClass: Error }
console.log(readMeta(human.walk));
// => { IS_ACTION: true, ActionType: "MOVEMENT", Symbol(ENTITY_TOKEN): "X_HUMAN_ENTITY" }

const child = new Child();
console.log(readMeta(child));
// => { BASE_BEHAVIOR: "passive", isTest: false, ErrorClass: Error }
console.log(readMeta(child.walk));
// => { isClumsy: true }

const fav = new FavoriteChild();
console.log(readMeta(child));
// => { isFavorite: true }
console.log(readMeta(child.walk));
// => { IS_ACTION: true, ActionType: "MOVEMENT", Symbol(ENTITY_TOKEN): "X_HUMAN_ENTITY" }

Meta assignment and reading tips and best practices

  1. Meta is accessible in other(upper) decorators
  2. If you need to set more than 2 meta props, please use object syntax
  3. Use meta as simple key/value dto storage. Do not try to put here functions
  4. Meta is not inherited if overridden in child

@MapErrors and @MapErrorsAsync

This decorator in used to intercept errors to catch and display more effectively one layer up

import { MapErrorsAsync } from "sweet-decorators";
import { FetchError } from "node-fetch";
import { PaymentError } from "../errors";

const fetchErrorMapper = (error: Error) => {
  if (error instanceof FetchError) {
    return new PaymentError(
      "Cannot connect to remote endpoint",
      PaymentError.Code.NetworkError
    );
  }

  return;
};

const refErrorMapper = (error: Error) => {
  if (error instanceof ReferenceError) {
    return new PaymentError(
      "Internal reference error",
      PaymentError.Code.InternalError
    );
  }

  return;
};

class PaymentSystem {
  // You can use multiple mappers for handle different types of errors separately
  @MapErrorsAsync(fetchErrorMapper, refErrorMapper)
  async finishPayment(id: string) {
    /* ... */
  }
}

// In some other file
const ps = new PaymentSystem();

app.post("/finish-3ds", async (req, res) => {
  try {
    const response = await ps.finishPayment(req.query.id);

    /* ... */
  } catch (error) {
    console.log(error);
    // => PaymentError(NETWORK_ERROR): Cannot connect to remote endpoint
  }
});

@MapErrors tips and best practices

  1. Mapper must return error (at least something nested from Error class)
  2. Mapper must return undefined, to pass control to next mapper
  3. Mapper must not throw an error
  4. Mapper must not have slow side effects (be perfect if the only side effect is sync & atomic logging)

Dependency injection via DIContainer

This is simple implementation of dependency injection pattern in typescript without assigning any metadata.

Example of injection of simple value

import { DIContainer } from "sweet-decorators";

const container = new DIContainer();
const SECRET_TOKEN = Symbol("SECRET_TOKEN");

container.provide(SECRET_TOKEN, process.env.SECRET_TOKEN);

class ExternalAPIClient {
  @container.Inject(SECRET_TOKEN)
  private readonly token!: string;

  public call(method: string, params: object) {
    params = { token: this.token, ...params };

    /* foreign api call logic */
  }

  public get secretToken() {
    return this.token;
  }
}

const client = new ExternalAPIClient();

console.log(client.secretToken === process.env.SECRET_TOKEN);
// => true

Example of providing a class

import { DIContainer } from "sweet-decorators";

const container = new DIContainer();

// You must give a name(token) to provided class
@container.Provide('CONFIG_SERVICE');
class ConfigService {
  // BAD BAD BAD. Constructor of a provider can't have params. Because his initialization is controlled by container
  constructor(public path: sting) {}

  get(propertyPath: string): any { /* ... */ }
}


class Database {
  @container.Inject('CONFIG_SERVICE')
  private readonly config: ConfigService;

  public connection = this.config.get('db.connectionString')

  /* ... logic ... */
}

Example of usage injectAsync method

import { DIContainer } from "sweet-decorators";

const container = new DIContainer();

setTimeout(
  () =>
    container.provide(
      "DB_SERVICE",
      new DB({
        /* ... */
      })
    ),
  5000
);

async function main() {
  const start = Date.now();

  const db = await container.injectAsync("DB_SERVICE");

  const time = Date.now() - start;

  console.log(time);

  /* logic */
}

main();
// => 5005

Tips & best practices of DI

Common tips applicable to any realization of DI in TS.

  1. Provide classes, inject interfaces
  2. Do not be afraid of using Symbols as keys
  3. Make sure to have at least runtime check of dependency (ex. dep is not undefined)

Tips for using my realization of DI.

  1. If you're injecting dependency as property, please add !, to indicate TS that property will not be initialized in constructor
  2. Check property dependencies in runtime, because they provided asynchronously by getter
  3. If you want to get dependencies reliably, you can use injectAsync method
  4. If dependency, that injectAsync method is waiting for, is not provided, it will hang execution of your code

Method hooks: @Before, @After, @Around, @BeforeAsync, @AfterAsync, @AroundAsync

This decorators used to call methods around other methods. Its can help make code more concise by moving similar aspects out of the method.

Simple Hooks Example

import { Before, After, Around } from "sweet-decorators";

function before(...args: any[]) {
  console.log("Before", { args });
}

function after(result: any[], ...args: any[]) {
  console.log("After", { result, args });
}

function around(fn: Function, ...args: any[]) {
  console.log("Before (Around)");

  fn(...args);

  console.log("After (Around)");

  return 43;
}

class Test {
  // Order of decorators matters
  @Before(before)
  @After(after)
  @Around(around)
  example(..._args: any[]) {
    console.log("Call Example");

    return 42;
  }
}

const result = new Test().example(1488);

console.log(result === 42);

/*
Before { args: [ 1488 ] }
Before (Around)
Call Example
After (Around)
After { result: 43, args: [ 1488 ] } // If you swap `@After` and `@Around` in function declaration, result will be 42
false 
// False, because function `around` changed it
*/

User Service Example

import { AroundAsync, AfterAsync, BeforeAsync } from "sweet-decorators";

function checkAuthorization(this: UserService) {
  if (!this.currentSession) {
    throw new UserError("Unauthorized");
  }
}

async function updateLastLogin(this: UserService, result: any, ...args: any[]) {
  if (result.success) {
    await this.db.query(/* ... */);
  }
}

async function handleErrors(this: UserService, fn: Function, ...args: any[]) {
  try {
    return await fn(...args);
  } catch (error) {
    if (error instanceof DBError) {
      throw new UserError("Database got wrong");
    }

    throw error;
  }
}

class UserService {
  @AroundAsync(handleErrors) // First decorator wraps all next
  // If you put it ^ last. It will wrap only the function content.
  // That's how decorators work
  // https://www.typescriptlang.org/docs/handbook/decorators.html#decorator-composition
  @AfterAsync(updateLastLogin)
  @BeforeAsync(checkAuthorization)
  async getPersonalData() {
    /* ... */
  }

  /* ... */
}

Hooks best practices & capabilities

Section may contain Cap's notices.

  1. Put your validation to @Before
  2. Put your metrics to @Around
  3. Put your side effects to @After
  4. Put error handling to @Around, except your project is good at using either monad
  5. Mix more than 2 of these decorators together only if you strongly know order of execution. If not, read the warning and linked article

Memoization of methods via @Memoize & @MemoizeAsync

This decorator is used to easily create memoized functions.

Memo's Limitations

By default, memo storage uses storage, that caches method's result only by 1st parameter. If you want to change this behavior you can create your own storage by implementing IMemoStorage interface.

Example of using @Memoize with Dates

import { Memoize } from "sweet-decorators";
import { promisify } from "util";

const sleep = promisify(setTimeout);

class Example {
  @Memoize()
  date() {
    return new Date().toString();
  }
}

const e = new Example();

async function main() {
  const now = e.date();

  await sleep(10);

  console.log(
    e.date() === now, // 10 ms passed, but result remembered
    now === new Date().toString()
  );
}

main();
// => true, false

Memo Tips & Best Practices

  1. You can bundle your fp methods to class, decorate, and then get methods back by using spreading.
  2. If you have troubles with returning cached result where is not supposed to do so, try reading limitations and writing your own memo store.
  3. @Memoize & @MemoizeAsync uses hooks @Around & @Around async under the hood. Please consider this while bundling your code.

Package Sidebar

Install

npm i sweet-decorators

Weekly Downloads

15

Version

1.9.0

License

MIT

Unpacked Size

116 kB

Total Files

51

Last publish

Collaborators

  • alexxandergrib