Patrick Desjardins Blog
Patrick Desjardins picture from a conference

TypeScript Injection of Value Using Decorator

Posted on: 2025-10-21

TypeScript is a great language for writing code that is type-safe and easy to read. However, sometimes you need to inject a value into a class or function. This can be done using a decorator.

In this article, we will see how we can inject from a single source of truth values into a class. We will also ensure we can know when the value are done injecting. The goal is that the injection might be asynchronous.

async function main(): Promise<void> {
    const t1 = new T();
    console.log("t1 before", ...t1.debug());
    await t1.injected;
    console.log("t1 after", ...t1.debug());
}
main();

Property Decorator

The first step is to create a property decorator that will inject the value on specific property of a class. In our example, we will use the @injectProp with will take 1 parameter. The parameter is the key (unique identifier) to the object/value we want to inject into a property. We will talk about the first parameter later.

const injectProp = locatorDecoratorFactory(loc);

The decorator logic resides in the locatorDecoratorFactory function. It will return a function that will be used as a decorator. The function will take 3 parameters. The first one is the target which is the class prototype. The second one is the property key. The third one is the descriptor. Because we want to specify the key, the decorator wraps the function with three parameters with a function that takes an id which is the unique identifier of the object/value we want to inject into a property.

function locatorDecoratorFactory<LocatorMap>(locator: Locator<LocatorMap>) {
    return function <ID extends LocatorID<LocatorMap>>(id: NoUnion<ID>) {
        return function(_target: undefined, context: ClassFieldDecoratorContext<unknown, LocatorMap[ID] | undefined>): void {
            const { metadata, name, access, addInitializer } = context;

            addInitializer(function () {
              // Will be implemented later
            })
        }
    }
}

The id is not a raw stringbecause it has to be from a key of the locator map (dictionary of key to object to inject). So, we have these utility types:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type NoUnion<Key> = [Key] extends [UnionToIntersection<Key>] ? Key : never;
type LocatorID<LocatorMapType> = keyof LocatorMapType & string;
type Constructor<T = any> = new (...args: any[]) => T;

The injection of the value into the property is done in the addInitializer function. This function is called when the class is instantiated, it sees the decorator, call the function to set the value.

function locatorDecoratorFactory<LocatorMap>(locator: Locator<LocatorMap>) {
    return function <ID extends LocatorID<LocatorMap>>(id: NoUnion<ID>) {
        return function(_target: undefined, context: ClassFieldDecoratorContext<unknown, LocatorMap[ID] | undefined>): void {
            const { metadata, name, access, addInitializer } = context;

            // The addInitializer function is used to add the initializer for the property.
            addInitializer(function () {
                // The class will keep track of all injected properties into its metadata. The goal: know when all injected properties are completed (async)
                const instancesMap = (metadata.decoratorInjectionPromise ??= new WeakMap()) as WeakMap<object, unknown>;
                // Get the dictionary that will keep the property->promise mapping for the instance
                const meta = getInstanceMeta(instancesMap, this);
                // Create a promise to resolve when the value is injected for this particular property
                const { resolve, promise } = Promise.withResolvers<void>();
                // Store the promise in the metadata for the instance. In case of many injections with the decorator, the instance will have more than one promise to resolve.
                meta[name] = promise;
                // Get the value from the locator. This is THE part that set the value into a single decorated property
                locator.get(id as NoUnion<ID>).then((result: LocatorMap[ID]) => {
                    // Access is the property, we set the value into the property
                    access.set(this, result);
                    resolve(); // Mark as completed, that way we can know if all the injected property are completed
                });
            })
        }
    }
}

The function aboves retrieve information using the context parameter. The metadata is used to store the metadata for the instance. The name is the name of the property. The access is used to set the value of the property. The addInitializer is used to add the initializer for the property.

The getInstanceMeta function is used to get the metadata for the promise. It is a function that return a record of the promises for the instance. The goal is to have a single promise for the instance and not one promise for each property.

function getInstanceMeta(keyValuePromise: WeakMap<object, unknown>, instance: unknown): Record<string | symbol, Promise<void>> {
    const thisKey = instance as object;
    const metaValue = keyValuePromise.get(thisKey) as Record<string | symbol, Promise<void>>;
    const meta = metaValue ?? {};
    if (!metaValue) {
        keyValuePromise.set(thisKey, meta);
    }
    return meta;
}

Locator

The locator role is to store the objects/values and to provide them when requested. In our example, we will use a simple locator that will store the objects/values in a map. In reality, you would use a dependency injection container to store the objects/values and to provide them when requested.

class Locator<M> {
    constructor(private map: M) {}
    async get<ID extends LocatorID<M>>(id: NoUnion<ID>): Promise<M[ID]> {
        await new Promise(resolve => setTimeout(resolve, 100)); // Simulate a delay to simulate real scenarios
        return this.map[id]; // In our case, we return the already instantiated value from the map
    }
}

Class Decorator

A feature we are adding, which is optional but useful, is to have a class decorator that will wait for all the injected properties to be completed. This is useful when you have a class that has many injected properties and you want to wait for all of them to be completed before doing something.

function injectable() {
    return function (target: Constructor, context: ClassDecoratorContext): Constructor {
        return class extends (target as any) {
            readonly injected = Promise.all(Object.values(getInstanceMeta(context.metadata.decoratorInjectionPromise as WeakMap<object, unknown>, this)));
        }
    }
}

The class decorator is called injectable and add to the class a property injected that is a promise that will resolve when all the injected properties are completed. We can see that the injected property gets all instances from the metadata. The instances was injected in the property decorator using const instancesMap = (metadata.instances ??= new WeakMap()) as WeakMap<object, unknown>;.

Testing utility

To simplify the testing, we can create a utility class Injectable that will declare the injected property. This way, we can use the untilInjected function to wait for all the injected properties to be completed.

abstract class InjectableDecorators {
    declare readonly injected: Promise<void>;
}

function waitUntilAllInjected<T = unknown>(target: T): Promise<void> {
    const injected = (target as InjectableDecorators).injected;
    return typeof (injected as Promise<void>)?.then === "function" ? injected : Promise.resolve();
}

Test

Creating few classes and a fake dependency injection container, we can test the scenario.

class A {
    readonly id = "A";
}
class B {
    readonly id = "B";
}

interface LocatorMap {
    A: A;
    B: B;
}
// This is the variable we pass to the creation of the locator.
const loc: Locator<LocatorMap> = new Locator<LocatorMap>({
    A: new A(),
    B: new B()
});

Testing is a matter of using the decorators.

@injectable()
class T {
    @injectProp("A") 
    private _a?: A;
    @injectProp("B") 
    private _b?: B;
    json() {
        return {"_a": this._a, "_b": this._b};
    }
}

async function main(): Promise<void> {
    const t1 = new T();
    console.log("Before", t1.json());
    await waitUntilAllInjected(t1);
    console.log("After", t1.json());
}
void main();

The properties are all undefined before the injection. After the injection, the properties are set to the values from the locator. We know exactly when the values are injected because we are using the untilInjected function.

[LOG]: " t1 before injection",  "_a:",  undefined,  "_b:",  undefined 
[LOG]: " t1 after injection",  "_a:",  A: {
  "id": "A"
},  "_b:",  B: {
  "id": "B"
} 

Conclusion

This blog shows that we can inject from a dictionary that contain instances of objects/values into a class. We added a delay which simulate that the locator might not have the value already instanciated when creating the dictionary and could resolve it in a later stage (lazy loading). The delay make the code asynchronous and could be a performance improvement for heavy object that require several other objects to be injected. It is not rare to see a cascade of dependencies that could result to a complex initialization process. The decorator on the class allows to wait before proceeding. Waiting for a whole object to be injected is crucial when an object depends on other objects to be injected.

You can play with the code here.