Skip to content

Code-generated Dependency Injection for Typescript

Notifications You must be signed in to change notification settings

mako-taco/dipstick

Repository files navigation

Chat-GPT-Image-Jun-29-2025-04-26-18-PM.png

Dipstick is a dependency injection framework for TypeScript. Instead of using @Decorators, reflect-metadata, or unique strings to identify objects to the DI framework, Dipstick relies on the type system and code generation. The result is a DI framework that works with the strengths of typescript, instead of hacking around it's weaknesses.

Simple.

Unlike other DI frameworks, which can sometimes feel like learning an entirely new language, learning Dipstick only requires understanding two concepts: Containers and Bindings.

Obvious.

The principle of least surprise is fundamental to Dipstick. There are no magic decorators or obtuse indirections in code. All of your IDEs tools like "Find references" and "go to symbol" work just as well with Dipstick as without. If you know typescript, understanding the code generated by Dipstick is a piece of cake.

Type Safe.

Dipstick works with the type system, instead of around it. Use the same Container types you declared to generate code in your integration tests to provide mocks for specific Containers. Don't worry about a DI framework spreading any throughout your codebase -- the types that come out of Dipstick are exactly as strong as you author them to be.

Installation

npm install dipstick

Overview

Dipstick uses TypeScript's type system and code generation to create dependency injection containers. The framework supports both class instantiation and factory functions as implementations, making it flexible for various architectural patterns. The framework is designed to be type-safe and easy to use, with a focus on maintainability and developer experience.

Step 1: Define a container in real typescript types

import { Container, Reusable, Transient } from 'dipstick';

export type AppContainer = Container<{
  bindings: {
    database: Reusable<Database>;
    logger: Reusable<Logger, ILogger>;
    userService: Transient<UserService, IUserService>;
  };
}>;

Step 2: Run code generation to implement your container

npx dipstick generate ./path/to/tsconfig.json

Step 3: Instantiate your generated container

// Instantiate your container
const container = new AppContainerImpl();

// Create an IUserService, automatically resolving dependencies
const userService = container.userService();
await userService.getUser('123');

Check out a TODO app using dipstick here.

Core Concepts

Containers

Containers are the core building blocks of Dipstick. They allow you to bind implementations to types that are used throughout your project. An instance of a container is akin to a "scope" in other DI frameworks -- the container instance will hold references to reusable bindings and static bindings. To create a container, export a type alias to Container, and define its bindings:

import { Container, Reusable, Transient } from 'dipstick';

// Export a type thats assignable to `Container` for dipstick to pick it up during code generation
export type MyContainer = Container<{
  bindings: {
    // Class bindings
    foo: Reusable<Foo, IFoo>;
    bar: Transient<Bar, IBar>;

    // Function binding using typeof syntax
    baz: Reusable<typeof createDatabase>;
  };
}>;

Bindings

Bindings allow containers to associate an implementation with a type. All bindings take two type arguments. The first argument can be either a class (which will be instantiated) or a function (using the typeof syntax). The second, optional argument is a type to return the instance as, such as an interface. Within a single container, no two bindings may return the same type alias.

export type MyContainer = Container<{
  bindings: {
    userIface: Transient<User, IUser>;
    userImpl: Transient<User>;
  };
}>;
const container = new MyContainerImpl(); // MyContainerImpl is generated code

const userImpl = container.userImpl(); // User
const userIface = container.userIface(); // IUser

Using typeof with Factory Functions

When you use typeof functionName as the first type argument with only one argument, Dipstick automatically infers the bound type as ReturnType<typeof functionName>. This is particularly useful for factory functions:

// A factory function that returns a request handler
function createUserHandler(userService: IUserService) {
  return (req: Request, res: Response) => {
    const users = userService.getAll();
    res.json(users);
  };
}

export type HandlerContainer = Container<{
  bindings: {
    // Bound type is automatically ReturnType<typeof createUserHandler>
    userHandler: Reusable<typeof createUserHandler>;
  };
}>;

Other containers can then depend on HandlerContainer and receive the handler type:

class App {
  constructor(
    // Type is ReturnType<typeof createUserHandler>
    private readonly userHandler: ReturnType<typeof createUserHandler>
  ) {
    this.app.get('/users', this.userHandler);
  }
}

export type AppContainer = Container<{
  bindings: {
    app: Reusable<App>;
  };
  dependencies: [HandlerContainer];
}>;

Dipstick will automatically match the ReturnType<typeof createUserHandler> parameter to the userHandler binding from HandlerContainer.

Bindings come in three flavors, which are described below.

Reusable Bindings

Reusable bindings return the same instance every time they are called. This is useful for singletons or other objects that should only be created once per container:

export type MyContainer = Container<{
  bindings: {
    // Returns the same Foo instance every time
    foo: Reusable<Foo, IFoo>;
  };
}>;

Transient Bindings

Transient bindings return a new instance every time they are called. This is useful for objects that should be created fresh each time they are requested:

export type MyContainer = Container<{
  bindings: {
    // Returns a new Bar instance every time
    bar: Transient<Bar>;
  };
}>;

Static Bindings

Static bindings are used to provide objects to a container when the container is instantiated. Use static bindings when you want to incorporate an object created outside of dipstick into a container so that it can be used as a dependency of other objects.

class RequestHandler {
  constructor(req: Request, res: Response) {}

  execute() {
    res.send(200, `hello ${req.path}`);
  }
}

export type RequestContainer = Container<{
  bindings: {
    // Created outside of this container
    req: Static<Request>;
    res: Static<Request>;

    requestHandler: Transient<RequestHandler>;
  };
}>;

app.use((req, res) => {
  const container = new MyContainer({ req, res });
  const handler = container.requestHandler();
  handler.execute();
});

Modularity & Composition

Containers can depend on other containers. These dependencies are used to resolve types that the container cannot resolve itself:

class Foo {
  constructor(bar: Bar) {}
}

export type FooContainer = Container<{
  bindings: {
    foo: Reusable<Foo>;
  };
}>;

export type BarContainer = Container<{
  dependencies: [FooContainer];
  bindings: {
    bar: Transient<Bar>;
  };
}>;

const fooContainer = new FooContainer();
const barContainer = new BarContainer([fooContainer]);

// if a container has both dependencies and static bindings, pass both:
// const barContainer = new BarContainer({ baz: new Baz() }, [ fooContainer ])

Usage

  1. Define your containers using type aliases to Container
  2. Run the code generator:
    npm exec -- dipstick generate ./path/to/tsconfig.json --verbose
  3. Use the generated containers in your application:
    const myContainer = new MyContainerImpl();
    const service = myContainer.myService();
    ...

Code Generation

The code generator will:

  1. Scan your TypeScript files for exported container type aliases
  2. Generate implementation classes for each container
  3. Handle dependency injection and binding resolution
  4. Ensure type safety throughout the dependency graph

Contributing

Contributions are welcome! Please see our CONTRIBUTORS.md guide for detailed information about:

  • Setting up the development environment
  • Running tests and code quality checks
  • Code style guidelines
  • Submitting pull requests

For quick contributions:

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Run npm run build && npm run check && npm test
  5. Submit a Pull Request

About

Code-generated Dependency Injection for Typescript

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published