-
Functional Pull-Oriented Dependency Injection - Every function receives dependencies through a
deps
parameter. No imports of other functions, no side effects in pure functions. -
Result Type Pattern - Functions that can fail return
Result<T, E>
. Functions that cannot fail return values directly. -
Deep Imports Only - No barrel files. Import directly from specific files:
import {...} from '@tinkerbot/pure/git.ts'
-
Workspace Protocol - Internal dependencies use
workspace:*
protocol in package.json -
Turbo Pipeline - Build orchestration follows dependency graph:
test
runs beforebuild
build
runs beforestart
dev
depends on database being up
git, typescript, bun, ai agents, markdown, react 19 a little bit of Zig and Rust nix flakes avoid docker unless absolutely necessary
use bun, not npm nor pnpm nor yarn bunx, not npx
do not use branded string types unless it actually helps catch bugs. let's not be pedantic nuts
no string literal types; create specific unbranded types for each conceptually different kind of string
- barrel files are forbidden. deeply import stuff. less busy work and boilerplate
- entrypoints may have the suffix Entrypoint e.g. createTodoAppEntrypoint so that it's obvious at a glance that it's allowed to import stuff and stuff
Reusable code is organized into three packages under packages/
:
- Location:
packages/contracts/
- Purpose: All type definitions, interfaces, dependency wrappers (*Dep), and Result types
- Examples:
Result<T, E>
,ErrorWithName
, domain types, dependency interfaces - Import:
import type {...} from '@tinkerbot/contracts/result.ts'
- Location:
packages/pure/
- Purpose: All pure functions with zero side effects that follow Pull-Oriented DI
- Examples: Business logic, transformations, validations
- Import:
import {...} from '@tinkerbot/pure/git.ts'
- Location:
packages/pure-bun/
- Purpose: Impure code, entrypoints, and runtime-specific implementations
- Examples: Application entrypoints, Bun-specific adapters
- Import:
import {...} from '@tinkerbot/pure-bun/todo-app.ts'
Always use deep imports (no barrel files). Place new shared code in the appropriate package based on its purity and purpose.
Purpose – Make every fresh contractor or AI assistant instantly productive, aligned with our zero‑surprise coding style.
- Pure functions only – absolutely no side‑effects (I/O, logging, mutation) inside any function that's exported from a library file.
- Function arguments – if a function needs dependencies, pass a single
deps
kwargs object. If it needs no dependencies, just use normal arguments. Don't create empty deps objects. - Minimal surface –
Deps
type lists only what the function actually touches. Over‑providing at call‑site is fine; over‑depending is forbidden. - Zero imports – the body may not import/require anything. Every dependency must come through
deps
. This includes other pure functions - they must be passed through deps, not imported. Exception: foundational types likeResult
,ErrorWithName
from contracts are allowed. - Return shape – Only use
Result<Good, Bad>
when failure is possible. If a function can't fail, just return the value directly. - Composable – no singleton state, no ambient context, no service locators.
- Naming – dependency wrappers end with
Dep
; errors areErrorWithName<'MyError'>
or richer unions. - File endings – implementation files live under
projects/<project>/src/
, tests mirror path under__tests__/
. - Entrypoints – impure, side‑effectful, never exported, invoke
process.exit
on success/failure. - Order of work – start at the consumer code, then recurse into dependencies until leaf nodes are trivial.
Keep this list on your desk. Violations block merge.
Principle | Why it matters |
---|---|
Pure & deterministic | Makes reasoning, caching, retries, and tests simple. |
Pull, not push | Call‑site owns wiring; no hidden global graph. |
**Minimal **Deps |
Reduces coupling, speeds unit tests, highlights accidental reach. |
Result objects | Forces explicit happy ☀️ / sad ☔ paths; no swallowed exceptions. |
Top‑down design | We prototype with the nicest possible API, then satisfy it layer‑by‑layer, preventing leaky abstractions. |
// Always import these from your local util, never from random libs
export type Ok<T> = { ok: true; value: T }
export type Err<E> = { ok: false; error: E }
export type Result<T, E> = Ok<T> | Err<E>
export class ErrorWithName<const N extends string> extends Error {
constructor(public name: N, ...args: ConstructorParameters<typeof Error>) {
super(...args)
this.name = name
}
}
// Extract dependencies from a function
export type DepsOf<F> = F extends (deps: infer D, ...args: any[]) => any ? D : never
/** Converts text to kebab-case */
export const toKebabCase = (text: string): Result<string, ErrorWithName<'InvalidTaskName'>> => {
const kebab = text
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.trim()
if (!kebab) {
return { ok: false, error: new ErrorWithName('InvalidTaskName', 'Cannot convert empty text') } as const
}
return { ok: true, value: kebab } as const
}
interface Clock { now: () => number }
interface ClockDep { readonly clock: Clock }
/** Returns ISO timestamp string */
export const getNow = (deps: ClockDep): string => {
return new Date(deps.clock.now()).toISOString()
}
/** Formats a timestamp - can't fail */
export const formatTimestamp = (timestamp: number): string => {
return new Date(timestamp).toISOString().replace(/[-:]/g, '').slice(0, 15)
}
interface Db { query: (sql: string) => Promise<unknown[]> }
interface Logger { info: (...msg: unknown[]) => void }
interface DbDep { readonly db: Db }
interface LoggerDep { readonly logger: Logger }
type Deps = DbDep & Partial<LoggerDep>
export const findUserById = (deps: Deps) => async (id: string) => {
deps.logger?.info('findUserById', id)
try {
const rows = await deps.db.query('SELECT * FROM users WHERE id = ?', [id])
return { ok: true, value: rows[0] } as const
} catch (cause) {
return { ok: false, error: new ErrorWithName('DbError', String(cause), {cause}) } as const
}
}
// When composing curried functions, prepare them once
export const createUserService = (deps: Deps) => {
// Prepare all functions once when deps are provided
const findById = findUserById(deps)
const findByEmail = findUserByEmail(deps)
const createUser = createNewUser(deps)
return {
findById,
findByEmail,
createUser,
// Complex operation using the prepared functions
findOrCreate: async (email: string) => {
const existing = await findByEmail(email)
if (existing.ok) return existing
return createUser({ email })
}
}
}
// WRONG - importing functions directly
import { validateUser, hashPassword } from './auth.ts'
export const createUser = (deps: LoggerDep) => async (email: string, password: string) => {
const validResult = validateUser(email) // ❌ Imported function!
const hashed = await hashPassword(password) // ❌ Imported function!
// ...
}
// RIGHT - receive functions through deps
interface AuthOperations {
validateUser: (email: string) => Result<void, Error>
hashPassword: (password: string) => Promise<string>
}
interface AuthOperationsDep {
readonly auth: AuthOperations
}
export type CreateUserDeps = LoggerDep & AuthOperationsDep
export const createUser = (deps: CreateUserDeps) => async (email: string, password: string) => {
const validResult = deps.auth.validateUser(email) // ✅ From deps!
if (!validResult.ok) return validResult
const hashed = await deps.auth.hashPassword(password) // ✅ From deps!
deps.logger.log(`Creating user: ${email}`)
// ...
}
import { createBasicLogger } from './logger'
import { mainServer } from './server'
if (import.meta.main) {
const logger = createBasicLogger('app')
try {
await mainServer({ logger })
process.exit(0)
} catch (e) {
logger.error('fatal', e)
process.exit(1)
}
}
- Write dream call‑site – pretend the feature already exists; sketch the nicest possible API.
- Red squiggles == TODO list – each unresolved identifier becomes a dependency you must supply via
deps
. - Wrap each dep – declare
FooDep
around its interface. Add it to the parent'sDeps
intersection. - Implement leafs – when a dep itself needs collaborators, repeat. Stop when the logic is trivial or impure.
- Wire at composition root – entrypoint or test harness constructs the full
deps
object.
Never bottom‑up. Starting with utilities leaks details upward and ruins ergonomics.
- Each pure function is unit‑tested with inline fake deps.
- Use object literals; no jest mocks or ts‑mock‑import nonsense.
- Edge‑level adapters (HTTP handlers, CLI, schedulers) get integration tests only.
test('getNow returns ISO string', () => {
const fakeClock: Clock = { now: () => 0 }
const result = getNow({ clock: fakeClock })
expect(result).toBe('1970-01-01T00:00:00.000Z')
})
test('toKebabCase handles invalid input', () => {
const result = toKebabCase('')
expect(result.ok).toBe(false)
expect(result.error.name).toBe('InvalidTaskName')
})
- ESLint rule
no-restricted-imports
– disallow anything except relative insidesrc/
. - RegEx CI check – fails if file contains
import .* from
(except for type‑only imports in entrypoints). - Type‑only deps – runtime circular deps vanish by construction.
Pitfall | Fix |
---|---|
Leaking a console logger or clock via global |
Pass it through deps like everything else. |
Throwing known error cases | Return Err<E> instead. Reserve throw for truly unexpected bugs. |
Adding utility functions that import fs or process |
Those belong in an adapter module, never in pure logic. |
Importing other pure functions directly | Pass them through deps instead. No imports in pure functions! |
Marking every dep Partial<> |
If it's optional you probably don't need it; rethink. |
Q: Can I use classes? A: Only for data (immutables) or typed errors. No method state.
Q: What about async generators / streams?
A: Same rules. They still accept a single deps
object and return a Result
‐wrapped async iterator.
Q: I need to mutate a cache. Side effect?
A: Expose a cache interface through deps
, return the updated value; caller decides what to do.
- Evolu DI – https://www.evolu.dev/docs/dependency-injection
- Tom's
standard-pull-di
repo – canonical source.
Deliver with discipline. Any PR that diverges from these rules gets bounced. Few exceptions. 🚫
Do what has been asked; nothing more, nothing less. NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.