Skip to content

Commit

Permalink
feat: add @Singleton
Browse files Browse the repository at this point in the history
  • Loading branch information
ashgw committed Nov 22, 2024
1 parent 4a6c15e commit b627a38
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 18 deletions.
39 changes: 23 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
abstract requestFoo(secret: Secret, baz: Baz): Optional<T>;
public abstract requestFoo(secret: Secret, baz: Baz): Promise<Optional<T>>;
}

@Final
@Frozen
@Singleton
class Foo<T> extends BaseFoo<T> {
readonly foo: T;
bar: Optional<Bar>;
private static readonly rnd = Math.random();
private readonly foo: T;
public bar: Optional<Bar>; // `Bar` then becomes readonly with the decorator

constructor(foo: T, bar?: MaybeUndefined<Bar>) {
public constructor(foo: T, bar?: MaybeUndefined<Bar>) {
super();
this.foo = foo;
this.bar = bar ?? null;
}


requestFoo(secret: Secret, baz: Baz): Optional<T> {
// A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value
public override async requestFoo(
secret: Secret,
baz: Baz
): Promise<Optional<T>> {
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;
}
}

Expand All @@ -206,18 +211,20 @@ class SubFoo extends Foo<string> {
}

// No problem with instantiation
const foo = new Foo<string>('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'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "1.40.0",
"private": false,
"description": "Bullet-proof TS even more",
"type": "module",
"keywords": [
"typescript",
"utility",
Expand All @@ -24,7 +25,6 @@
"email": "contact@ashgw.me"
},
"sideEffects": false,
"type": "module",
"exports": {
"types": {
"import": "./dist/index.d.mts",
Expand Down
39 changes: 39 additions & 0 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,42 @@ export function Sealed<T extends Newable>(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<T extends Newable>(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;
}
};
}
87 changes: 87 additions & 0 deletions tests/singleton.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
3 changes: 2 additions & 1 deletion tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"lib": ["ESNext"],
"module": "ESNext",
"experimentalDecorators": true,
"moduleResolution": "Node",
"moduleResolution": "bundler",
"newLine": "LF",
"noEmit": true ,
"noEmitOnError": true,
"noErrorTruncation": true,
"noImplicitReturns": true,
Expand Down

0 comments on commit b627a38

Please sign in to comment.