Skip to content

Latest commit

Β 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

di

Small typed dependency injection built on thenables and async hooks.

min: 2.5 kB gz: 1.1 kB br: 1003 B

Installation

Copy src/di.ts into your project. Optionally, you may also copy src/di.test.ts.

Getting started

  1. Define the thing your code needs.
import { Dependency, Runtime } from "@/lib/di"

type Random = {
  readonly next: () => number
}

const Random = new Dependency<Random>("Random")
  1. Use it in your code by awaiting it.
async function program() {
  const random = await Random
  console.log(`random number: ${random.next()}`)
}
  1. Create a real implementation, then run your code with it.
const RandomLive = Random.make(() => ({
  next: () => Math.random(),
}))

const runtimeLive = new Runtime(RandomLive)
await runtimeLive.run(program) // stdout: random number: 0.8241872233134417
  1. In tests, swap in a test implementation.
const RandomTest = Random.make(() => ({
  next: () => 0.25,
}))

const runtimeTest = new Runtime(RandomTest)
await runtimeTest.run(program) // stdout: random number: 0.25

Default implementations

Use a default when most runtimes share one implementation. It also lets TypeScript infer the dependency type.

import { Dependency } from "@/lib/di"

export const Random = new Dependency("Random", () => ({
  next: () => Math.random(),
}))

export const RandomTest = Random.make(() => ({
  next: () => 0.25,
}))

Derived dependencies

Pass an async function to build one dependency from another.

import { createClient } from "@libsql/client"
import { drizzle } from "drizzle-orm/libsql"
import { Dependency } from "@/lib/di"

export const Database = new Dependency("Database", () =>
  createClient({
    url: "file:sqlite.db",
  }),
)

export const Drizzle = new Dependency("Drizzle", async () => {
  const database = await Database
  return drizzle(database)
})

Finalizers

Use finalizers for resources like database clients, sockets, or file handles. Finalizers execute when the enclosing Runtime.run() exits, even if it throws.

import { createClient } from "@libsql/client"
import { Dependency, Runtime } from "@/lib/di"

export const Database = new Dependency(
  "Database",
  () =>
    createClient({
      url: "file:sqlite.db",
    }),
  (client) => {
    client.close()
    console.log("SQLite client closed")
  },
)

const runtime = new Runtime(Database.Default)
await runtime.run(async () => {
  console.log("Using database")
  throw Error("Oops")
})

// stdout:
// Using database
// SQLite client closed

Scoped resources

Use scoped resources for one-off runtime-owned things like temp workspaces, file handles, or log streams that should be cleaned up automatically without becoming full Dependency values.

import { mkdtemp, open, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Runtime, acquireRelease, acquireUseRelease } from "@/lib/di"

async function program() {
  const workspace = await acquireRelease(
    () => mkdtemp(join(tmpdir(), "di-export-")),
    (path) => rm(path, { recursive: true, force: true }),
  )

  await writeFile(join(workspace, "users.csv"), "id,name\n1,Jai Dixit\n")

  await acquireUseRelease(
    () => open(join(workspace, "summary.txt"), "w"),
    (file) => file.writeFile("export complete\n"),
    (file) => file.close(),
  )
}

await new Runtime().run(program)

Cancellation

Use cancellation when dependencies do I/O and should stop with the request or job. Runtime.run() provides one scoped AbortSignal, and signal() reads it anywhere in that runtime.

import { Dependency, Runtime, signal } from "@/lib/di"

type FeatureFlags = {
  readonly newCheckout: boolean
  readonly referralBanner: boolean
}

export const FeatureFlags = new Dependency<FeatureFlags>("FeatureFlags")

export const FeatureFlagsMock = FeatureFlags.make(() => ({
  newCheckout: true,
  referralBanner: false,
}))

export const FeatureFlagsLive = FeatureFlags.make(async () => {
  const response = await fetch("https://flags.internal.example/api/flags", {
    signal: signal(),
  })

  return (await response.json()) as FeatureFlags
})

async function program() {
  const flags = await FeatureFlags
  console.log(flags.newCheckout)
}

const controller = new AbortController()

await new Runtime(FeatureFlagsMock).run(program)

await new Runtime(FeatureFlagsLive).run(program, {
  signal: controller.signal,
})

Nested runtimes inherit the parent signal. If a child runtime also receives a signal, both signals are composed.

License

MIT

About

🍩 Minimal dependency injection

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages