npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sandly

v2.1.0

Published

Sandly ("Services And Layers") is a type-safe dependency injection library for TypeScript. No decorators, no runtime reflection, just compile-time safety that catches errors before your code runs.

Readme

Sandly

Sandly ("Services And Layers") is a type-safe dependency injection library for TypeScript. No decorators, no runtime reflection, just compile-time safety that catches errors before your code runs.

Why Sandly?

Most TypeScript DI libraries rely on experimental decorators and runtime reflection, losing type safety in the process. Sandly takes a different approach: the container tracks every registered dependency at the type level, making it impossible to resolve unregistered dependencies at compile time.

import { Container, Layer } from 'sandly';

class Database {
	query(sql: string) {
		return [];
	}
}

class UserService {
	constructor(private db: Database) {}
	getUsers() {
		return this.db.query('SELECT * FROM users');
	}
}

// Define layers
const dbLayer = Layer.service(Database, []);
const userLayer = Layer.service(UserService, [Database]);

// Compose and create container
const container = Container.from(userLayer.provide(dbLayer));

// TypeScript knows UserService is available
const users = await container.resolve(UserService);

// TypeScript error - OrderService not registered
const orders = await container.resolve(OrderService);

Key features:

  • Compile-time safety: TypeScript catches missing dependencies before runtime
  • No decorators: Works with standard TypeScript, no experimental features
  • Async support: Factories and cleanup functions can be async
  • Composable layers: Organize dependencies into reusable modules
  • Scoped containers: Hierarchical dependency management for web servers
  • Zero dependencies: Tiny library with no runtime overhead

Installation

npm install sandly
# or
pnpm add sandly
# or
yarn add sandly

Requires TypeScript 5.0+.

Quick Start

import { Container, Layer, Tag } from 'sandly';

// Any class can be a dependency - no special base class needed
class Database {
	async query(sql: string) {
		return [{ id: 1, name: 'Alice' }];
	}
	async close() {
		console.log('Database closed');
	}
}

class UserRepository {
	constructor(private db: Database) {}
	findAll() {
		return this.db.query('SELECT * FROM users');
	}
}

// Create layers
const dbLayer = Layer.service(Database, [], {
	cleanup: (db) => db.close(),
});

const userRepoLayer = Layer.service(UserRepository, [Database]);

// Compose layers and create container
const appLayer = userRepoLayer.provide(dbLayer);
const container = Container.from(appLayer);

// Use services
const repo = await container.resolve(UserRepository);
const users = await repo.findAll();

// Clean up
await container.destroy();

Core Concepts

Tags

Tags identify dependencies. There are two types:

Classes as tags: Any class constructor can be used directly as a tag:

class UserService {
	getUsers() {
		return [];
	}
}

// UserService is both the class and its tag
const layer = Layer.service(UserService, []);

ValueTags for non-class values: Use Tag.of() for primitives, objects, or functions:

const PortTag = Tag.of('Port')<number>();
const ConfigTag = Tag.of('Config')<{ apiUrl: string }>();

const portLayer = Layer.value(PortTag, 3000);
const configLayer = Layer.value(ConfigTag, {
	apiUrl: 'https://api.example.com',
});

Container

Containers manage dependency instantiation and lifecycle:

// Create from layers (recommended)
const container = Container.from(appLayer);

// Or build manually
const container = Container.builder()
	.add(Database, () => new Database())
	.add(
		UserService,
		async (ctx) => new UserService(await ctx.resolve(Database))
	)
	.build();

// Resolve dependencies
const db = await container.resolve(Database);
const [db, users] = await container.resolveAll(Database, UserService);

// Use and discard pattern - resolves, runs callback, then destroys
const result = await container.use(UserService, (service) =>
	service.getUsers()
);

// Manual clean up
await container.destroy();

Each dependency is created once (singleton) and cached.

Layers

Layers are composable units of dependency registrations:

// Layer.service for classes with dependencies
const userLayer = Layer.service(UserService, [Database, Logger]);

// Layer.value for constants
const configLayer = Layer.value(ConfigTag, { port: 3000 });

// Layer.create for custom factory logic
const cacheLayer = Layer.create({
	requires: [ConfigTag],
	apply: (builder) =>
		builder.add(Cache, async (ctx) => {
			const config = await ctx.resolve(ConfigTag);
			return new Cache({ ttl: config.cacheTtl });
		}),
});

Compose layers with provide(), provideMerge(), and merge():

// provide: satisfy dependencies, expose only this layer's provisions
const appLayer = userLayer.provide(dbLayer);

// merge: combine independent layers
const infraLayer = Layer.merge(dbLayer, loggerLayer);
// or
const infraLayer = Layer.mergeAll(dbLayer, loggerLayer, cacheLayer);

// provideMerge: satisfy dependencies and expose both layers
const fullLayer = userLayer.provideMerge(dbLayer);

Scoped Containers

Scoped containers enable hierarchical dependency management:

// Application scope - use builder to add dependencies
const appContainer = ScopedContainer.builder('app')
	.add(Database, () => new Database())
	.build();

// Request scope - use child() to create a child builder
const requestContainer = appContainer
	.child('request')
	.add(RequestContext, () => new RequestContext())
	.build();

// Child can resolve both its own and parent dependencies
const db = await requestContainer.resolve(Database); // From parent
const ctx = await requestContainer.resolve(RequestContext); // From child

// Destroy child without affecting parent
await requestContainer.destroy();

Or use layers with childFrom:

const appContainer = ScopedContainer.from('app', dbLayer);
const requestContainer = appContainer.childFrom(
	'request',
	Layer.value(RequestContext, new RequestContext())
);

Use and Discard Pattern

The use() method resolves a service, runs a callback, and automatically destroys the container:

// Perfect for short-lived operations like Lambda handlers or worker jobs
const result = await appContainer
	.childFrom('request', requestLayer)
	.use(UserService, (service) => service.processEvent(event));
// Container is automatically destroyed after callback completes

This is especially useful for serverless functions or message handlers where the container lifecycle matches a single operation.

Working with Layers

Creating Layers

Layer.service: Class dependencies with automatic injection

class ApiClient {
	constructor(
		private config: Config,
		private logger: Logger
	) {}
}

// Dependencies must match constructor parameters in order
const apiLayer = Layer.service(ApiClient, [Config, Logger]);

// With cleanup function
const dbLayer = Layer.service(Database, [], {
	cleanup: (db) => db.close(),
});

Layer.value: Constant values or pre-instantiated instances

// ValueTag (constants)
const ApiKeyTag = Tag.of('apiKey')<string>();
const configLayer = Layer.value(ApiKeyTag, process.env.API_KEY!);

// ServiceTag (pre-instantiated instances)
class UserService {
	getUsers() {
		return [];
	}
}
const userService = new UserService();
const testLayer = Layer.value(UserService, userService);

Layer.mock: Partial mocks for testing (ServiceTags only)

class UserService {
	constructor(private db: Database) {}
	getUsers() {
		return this.db.query('SELECT * FROM users');
	}
	getUserById(id: number) {
		return this.db.query(`...`);
	}
}

// Mock only the methods you need - no constructor dependencies required
const testLayer = Layer.mock(UserService, {
	getUsers: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
});

// TypeScript still validates the mock's method signatures

Layer.create: Custom factory logic

const dbLayer = Layer.create({
	requires: [ConfigTag],
	apply: (builder) =>
		builder.add(Database, async (ctx) => {
			const config = await ctx.resolve(ConfigTag);
			const db = new Database(config.dbUrl);
			await db.connect();
			return db;
		}),
});

Composing Layers

// Build your application layer by layer
const configLayer = Layer.value(ConfigTag, loadConfig());
const dbLayer = Layer.service(Database, [ConfigTag]);
const repoLayer = Layer.service(UserRepository, [Database]);
const serviceLayer = Layer.service(UserService, [UserRepository, Logger]);

// Compose into complete application
const appLayer = serviceLayer
	.provide(repoLayer)
	.provide(dbLayer)
	.provide(configLayer)
	.provide(Layer.service(Logger, []));

// Create container - all dependencies satisfied
const container = Container.from(appLayer);

Layer Type Safety

Layers track their requirements and provisions at the type level:

const dbLayer = Layer.service(Database, []);
// Type: Layer<never, typeof Database>

const userLayer = Layer.service(UserService, [Database]);
// Type: Layer<typeof Database, typeof UserService>

const appLayer = userLayer.provide(dbLayer);
// Type: Layer<never, typeof UserService>

// Container.from only accepts layers with no requirements
const container = Container.from(appLayer); // OK

const incomplete = Layer.service(UserService, [Database]);
const container = Container.from(incomplete); // Type error!

Scoped Containers

Request Scoping for Web Servers

import { ScopedContainer, Layer } from 'sandly';

// App-level dependencies (shared across requests)
const appContainer = ScopedContainer.from(
	'app',
	Layer.mergeAll(dbLayer, loggerLayer)
);

// Express middleware
app.use(async (req, res, next) => {
	// Create request scope with request-specific dependencies
	const requestScope = appContainer.childFrom(
		'request',
		Layer.value(RequestContext, {
			requestId: crypto.randomUUID(),
			userId: req.user?.id,
		})
	);

	res.locals.container = requestScope;

	res.on('finish', () => requestScope.destroy());
	next();
});

// Route handler
app.get('/users', async (req, res) => {
	const userService = await res.locals.container.resolve(UserService);
	res.json(await userService.getUsers());
});

Destruction Order

When destroying a scoped container:

  1. Child scopes are destroyed first
  2. Then the current scope's finalizers run
  3. Parent scope is unaffected
const parent = ScopedContainer.builder('parent')
	.add(Database, {
		create: () => new Database(),
		cleanup: (db) => db.close(),
	})
	.build();

const child = parent
	.child('child')
	.add(Cache, { create: () => new Cache(), cleanup: (c) => c.clear() })
	.build();

await parent.destroy(); // Destroys child first (Cache.clear), then parent (Database.close)

Error Handling

Sandly provides specific error types for common issues:

import {
	UnknownDependencyError,
	CircularDependencyError,
	DependencyCreationError,
	DependencyFinalizationError,
} from 'sandly';

try {
	const service = await container.resolve(UserService);
} catch (error) {
	if (error instanceof CircularDependencyError) {
		console.log(error.message);
		// "Circular dependency detected for UserService: UserService -> Database -> UserService"
	}

	if (error instanceof DependencyCreationError) {
		// Get the original error that caused the failure
		const rootCause = error.getRootCause();
		console.log(rootCause.message);
	}
}

API Reference

Container

| Method | Description | | ------------------------------- | --------------------------------------------- | | Container.from(layer) | Create container from a fully resolved layer | | Container.builder() | Create a container builder | | Container.empty() | Create an empty container | | Container.scoped(scope) | Create an empty scoped container | | container.resolve(tag) | Get a dependency instance | | container.resolveAll(...tags) | Get multiple dependencies | | container.use(tag, fn) | Resolve, run callback, then destroy container | | container.destroy() | Run finalizers and clean up |

ContainerBuilder

| Method | Description | | ------------------------ | --------------------- | | builder.add(tag, spec) | Register a dependency | | builder.build() | Create the container |

Layer

| Method | Description | | -------------------------------------- | ----------------------------------------------- | | Layer.service(class, deps, options?) | Create layer for a class | | Layer.value(tag, value) | Create layer for a constant value | | Layer.mock(tag, implementation) | Create layer with mock (partial for ServiceTag) | | Layer.create({ requires, apply }) | Create custom layer | | Layer.empty() | Create empty layer | | Layer.merge(a, b) | Merge two layers | | Layer.mergeAll(...layers) | Merge multiple layers | | layer.provide(dep) | Satisfy dependencies | | layer.provideMerge(dep) | Satisfy and merge provisions | | layer.merge(other) | Merge with another layer |

ScopedContainer

| Method | Description | | ------------------------------------ | ------------------------------------------- | | ScopedContainer.builder(scope) | Create a new scoped container builder | | ScopedContainer.empty(scope) | Create empty scoped container | | ScopedContainer.from(scope, layer) | Create from layer | | container.child(scope) | Create child scope builder | | container.childFrom(scope, layer) | Create child scope from layer (convenience) |

Tag

| Method | Description | | ------------------ | --------------------------- | | Tag.of(id)<T>() | Create a ValueTag | | Tag.id(tag) | Get tag's string identifier | | Tag.isTag(value) | Check if value is a tag |

Testing

Sandly makes testing easy with Layer.mock(), which allows you to create partial mocks without satisfying constructor dependencies. Import your production layers and override dependencies with mocks:

// Production code (e.g., src/services/user-service.ts)
import { Layer } from 'sandly';
import { ResourcesRepository } from '../repositories/resources-repository';

export class UserService {
	constructor(private repo: ResourcesRepository) {}
	async getUsers() {
		return this.repo.listByCrawlId('crawl-123');
	}
}

// Layer definition in the same file
export const userServiceLayer = Layer.service(UserService, [
	ResourcesRepository,
]);

// Test file (e.g., src/services/user-service.test.ts)
import { Container, Layer } from 'sandly';
import { userServiceLayer } from './user-service';
import { ResourcesRepository } from '../repositories/resources-repository';

// Override production dependencies with mocks
const testLayer = userServiceLayer.provide(
	Layer.mock(ResourcesRepository, {
		listByCrawlId: async () => [
			{ id: '1', name: 'Alice' },
			{ id: '2', name: 'Bob' },
		],
	})
);

const container = Container.from(testLayer);
const userService = await container.resolve(UserService);

// Use the service - mock is automatically injected
const users = await userService.getUsers();
expect(users).toHaveLength(2);

Benefits:

  • ✅ No need to satisfy constructor dependencies for mocks
  • ✅ TypeScript validates mock method signatures
  • ✅ Works seamlessly with Layer.service() composition
  • ✅ Clear intent: mock() for tests, value() for production

Comparison with Alternatives

| Feature | Sandly | NestJS | InversifyJS | TSyringe | | -------------------------- | ------ | ------ | ----------- | -------- | | Compile-time type safety | ✅ | ❌ | ⚠️ Partial | ❌ | | No experimental decorators | ✅ | ❌ | ❌ | ❌ | | Async factories | ✅ | ✅ | ❌ | ❌ | | Framework-agnostic | ✅ | ❌ | ✅ | ✅ | | Layer composition | ✅ | ❌ | ❌ | ❌ | | Zero dependencies | ✅ | ❌ | ❌ | ❌ |

Choose Sandly when you want:

  • Type safety without sacrificing simplicity
  • DI without experimental decorators
  • Composable, reusable dependency modules
  • Easy testing with mock injection
  • Minimal bundle size and zero dependencies

License

MIT