diff --git a/README.md b/README.md index d21c9bc..2793ca9 100644 --- a/README.md +++ b/README.md @@ -156,46 +156,51 @@ Checkout the full [API reference](https://ts-roids.ashgw.me/) for all usage exam - [`@Final`]() - Marks an object final, as in one cannot inherit from it. - [`@Sealed`]() - [Seals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal) an object. - [`@Frozen`]() - [Freezes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) an object. +- [`@Singleton`]() - Ensures that only a single instance of the class can be created. #### Basic Usage Finalize and freeze objects ```ts import type { Optional, NewType, MaybeUndefined } from 'ts-roids'; -import { Final, Frozen } from 'ts-roids'; +import { Final, Frozen, Singleton } from 'ts-roids'; type Bar = NewType<'Bar', string>; type Baz = NewType<'Baz', string>; type Secret = NewType<'Secret', string>; abstract class BaseFoo { - abstract requestFoo(secret: Secret, baz: Baz): Optional; + public abstract requestFoo(secret: Secret, baz: Baz): Promise>; } @Final @Frozen +@Singleton class Foo extends BaseFoo { - readonly foo: T; - bar: Optional; + private static readonly rnd = Math.random(); + private readonly foo: T; + public bar: Optional; // `Bar` then becomes readonly with the decorator - constructor(foo: T, bar?: MaybeUndefined) { + public constructor(foo: T, bar?: MaybeUndefined) { super(); this.foo = foo; this.bar = bar ?? null; } - - requestFoo(secret: Secret, baz: Baz): Optional { - // A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value + public override async requestFoo( + secret: Secret, + baz: Baz + ): Promise> { if ( + Foo.rnd > 0.5 && secret.concat().toLowerCase() === '123' && baz.concat().toLowerCase() === 'baz' && this.bar !== null ) { - return this.foo; + return await Promise.resolve(this.foo); } - return null; // So you have to explicitly return null here. + return null; } } @@ -206,18 +211,20 @@ class SubFoo extends Foo { } // No problem with instantiation -const foo = new Foo('foo'); +const foo = new Foo('foo'); -// Since the object is final: +// The Singleton ensures the same instance is returned +const foo2 = new Foo('bar'); +console.log(foo2 === foo); // True +// Since the object is final: // The line below will cause a TypeError: Cannot inherit from the final class Foo -const _ = new SubFoo('subFoo'); +new SubFoo('subFoo'); // Since the object is frozen: - // The line below will cause a TypeError: Cannot add property 'requestFoo', object is not extensible -foo.requestFoo = () => { - return 'not foo'; +foo.requestFoo = async () => { + return await Promise.resolve('not foo'); }; // The line below will cause a TypeError: Cannot assign to read only property 'bar' diff --git a/package.json b/package.json index 7f531fd..f9f9ff2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.40.0", "private": false, "description": "Bullet-proof TS even more", + "type": "module", "keywords": [ "typescript", "utility", @@ -24,7 +25,6 @@ "email": "contact@ashgw.me" }, "sideEffects": false, - "type": "module", "exports": { "types": { "import": "./dist/index.d.mts", diff --git a/src/decorators.ts b/src/decorators.ts index b60d4d7..2e39420 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -129,3 +129,42 @@ export function Sealed(cst: T): T & Newable { } }; } + +/** + * When applied to a class, it ensures that only a single instance of the class can be created. + * If an attempt is made to create another instance, the existing instance will be returned. + * + * @remarks + * The `Singleton` pattern is often used for cases where a global state or resource needs to be shared across the entire application. + * It is common in cases like configuration settings or managing connections to a shared resource (e.g., a database or API). + * + * @example + * ```ts + * @Singleton + * class Database { + * constructor(public host: string, public port: number) {} + * } + * + * const db1 = new Database('localhost', 5432); + * const db2 = new Database('localhost', 5432); + * + * console.log(db1 === db2); // true, both db1 and db2 refer to the same instance + * ``` + * @see + * https://en.wikipedia.org/wiki/Singleton_pattern + */ + +export function Singleton(cst: T): T { + let instance: T; + + return class Singleton extends cst { + constructor(...args: any[]) { + if (instance) { + return instance; + } + super(...args); + instance = new cst(...args) as T; + return instance; + } + }; +} diff --git a/tests/singleton.test.ts b/tests/singleton.test.ts new file mode 100644 index 0000000..7ba385a --- /dev/null +++ b/tests/singleton.test.ts @@ -0,0 +1,87 @@ +import { Singleton } from 'src'; +import { test, expect } from 'vitest'; + +test('should return the same instance when attempting to create multiple instances', () => { + @Singleton + class Foo { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + const foo1 = new Foo('first'); + const foo2 = new Foo('second'); + + expect(foo1).toBe(foo2); + expect(foo1.value).toBe('first'); +}); + +test('should have no problem with instantiation', () => { + @Singleton + class Foo { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + expect(() => { + new Foo('value'); + }).not.toThrow(); +}); + +test('should ensure singleton across different invocations', () => { + @Singleton + class Foo { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + const foo1 = new Foo('foo'); + const foo2 = new Foo('bar'); + + expect(foo1).toBe(foo2); + expect(foo1.value).toBe('foo'); +}); + +test('should not allow overriding the singleton instance', () => { + @Singleton + class Foo { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + const foo1 = new Foo('initial'); + const foo2 = new Foo('changed'); + + expect(foo1).toBe(foo2); + expect(foo2.value).toBe('initial'); +}); + +test('should return the same instance even after multiple constructor calls', () => { + @Singleton + class Foo { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + const foo1 = new Foo('first'); + const foo2 = new Foo('second'); + const foo3 = new Foo('third'); + + expect(foo1).toBe(foo2); + expect(foo1).toBe(foo3); + expect(foo3.value).toBe('first'); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index c1faabc..7ee994d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,8 +11,9 @@ "lib": ["ESNext"], "module": "ESNext", "experimentalDecorators": true, - "moduleResolution": "Node", + "moduleResolution": "bundler", "newLine": "LF", + "noEmit": true , "noEmitOnError": true, "noErrorTruncation": true, "noImplicitReturns": true,