Configuration

Typed, file-based configuration with dot-notation access and zero magic.

Overview

@tekir/config provides a lightweight configuration registry. Config files are plain TypeScript modules that export an object. When tekir() is called with configDir: 'config', it imports every file in that directory and registers each one by filename. After that, read values via the config() function from TekirApp using dot notation.

bun add @tekir/config

Config Directory

Each file in config/ maps to one namespace in the registry:

config/
config/
├── app.ts          # Application-level settings
├── auth.ts         # Authentication guard options
├── cache.ts        # Cache driver options
├── cors.ts         # CORS options
├── database.ts     # Database connection options
├── hash.ts         # Hashing driver options
├── logger.ts       # Logger options
└── session.ts      # Session driver options

Config files typically import from #env so that environment variables flow through config into your application code:

config/app.ts
import env from '#env'

export default {
  name: env.APP_NAME,
  host: env.HOST,
  port: env.PORT,
  key:  env.APP_KEY,
  env:  env.NODE_ENV
}
config/database.ts
import env from '#env'
import type { DatabaseConfig } from '@tekir/db'

export default {
  default: 'sqlite',
  connections: {
    sqlite: {
      driver: 'sqlite',
      connection: { path: env.DB_PATH }
    }
  }
} satisfies DatabaseConfig

Files that don't depend on environment variables are even simpler:

config/cache.ts
import type { CacheConfig } from '@tekir/cache'

export default {
  default: 'memory',
  stores: {
    memory: { driver: 'memory', ttl: 3600 }
  }
} satisfies CacheConfig

Automatic Loading

Pass configDir to tekir() and it loads every .ts and .js file in that directory at boot. Each file is registered under its filename without extension, drop a file into the directory and it becomes available immediately.

// Pass configDir to tekir() so it loads every .ts/.js file from that
// directory, registering each one under its filename (without extension):
//
// config/app.ts      → config('app')
// config/database.ts → config('database')
// config/cache.ts    → config('cache')
//
// index.ts:
import { tekir } from '@tekir/core'

await tekir({ configDir: 'config' })

// Drop a new file in config/, no manual registration needed.

Omit configDir entirely if you do not have a config directory. tekir does not scan anything by default and will not complain when the option is missing. For single-file apps you can pass config inline via tekir({ config: { ... } }) instead.

Reading Values

The config() function is available via TekirApp destructuring in start/ files. The first segment of the key is the registered config name; subsequent segments navigate into the object:

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { cors } from '@tekir/cors'

export default function({ router, config }: TekirApp) {
  // Top-level key: returns the whole config/cors.ts export
  router.useGlobal([cors(config('cors'))])

  // Nested key using dot notation
  const appName = config('app.name')      // 'My App'
  const dbPath  = config('database.connections.sqlite.connection.path')
}

In application code (controllers, services), import env directly from #env. Config is primarily used in start/ files and provider setup:

// In controllers or other files, use config from TekirApp in start/ files,
// or read env directly in application code
import env from '#env'
import type { HttpContext } from '@tekir/core'

export function show({ response }: HttpContext) {
  return response.ok({
    app: env.APP_NAME,
    env: env.NODE_ENV
  })
}

Dot Notation & Defaults

The key is split on . and traversed depth-first. Provide a second argument as the fallback value returned when the key resolves to undefined or null, or when the top-level config name has not been registered:

// Second argument is the fallback, returned when the key
// does not exist or resolves to undefined / null.
const timeout = config('cache.timeout', 60)     // 60 if key not found
const prefix  = config('cache.prefix', 'app:')  // 'app:' if key not found

// Top-level key with fallback
const redisUrl = config('redis.url', 'redis://localhost:6379')

Validation Schemas

register(name, value, schema?) accepts an optional validator function. It receives the value and returns true to accept, or false / an error string to reject. Invalid configuration throws at registration time, so a missing or wrong-typed value surfaces immediately instead of failing later at the point of consumption. The argument is optional, so existing calls keep working.

import { createConfigStore } from '@tekir/config'

const store = createConfigStore()

// register(name, value, schema?) accepts an optional validator.
// Return true to accept, or false / an error string to reject.
// Invalid config fails loudly at registration time, not deep in your app.
store.register('app', { port: 4000 }, (value) => {
  if (typeof value.port !== 'number') return 'app.port must be a number'
  return true
})

// Throws: 'app.port must be a number'
store.register('app', { port: '4000' }, (value) =>
  typeof value.port === 'number'
)

getAll() & Redaction

getAll() redacts sensitive values by default. Keys such as password, secret, token, apiKey, accessKey, privateKey, credential, auth, and dsn are replaced with [REDACTED], matched case-insensitively and at any nesting depth, so dumping config to a diagnostics endpoint or a log no longer leaks credentials. The stored values are never mutated. Pass { redact: false } when a trusted internal caller needs the raw values.

import { createConfigStore } from '@tekir/config'

const store = createConfigStore()
store.register('mail', { host: 'smtp.example.com', password: 's3cret' })

// getAll() redacts sensitive keys by default (password, secret, token,
// apiKey, accessKey, privateKey, credential, auth, dsn, ...), matched
// case-insensitively and at any nesting depth.
store.getAll()
// → { mail: { host: 'smtp.example.com', password: '[REDACTED]' } }

// Opt out for trusted internal callers that need raw values.
store.getAll({ redact: false })
// → { mail: { host: 'smtp.example.com', password: 's3cret' } }

createConfigStore()

createConfigStore() returns an isolated config store instance with register(), get(), getAll(), and loadDir() methods. Each tekir() call creates its own store, so multiple tekir apps in the same process get isolated config. You can also create one manually for testing:

import { createConfigStore } from '@tekir/config'

// createConfigStore() returns an isolated config store instance.
// Useful for testing or multi-tenant setups.
const store = createConfigStore()

// Register config namespaces manually
store.register('cache', { driver: 'memory', ttl: 3600 })
store.register('app', { name: 'Test App', port: 4000 })

// Read values with dot notation
const driver = store.get('cache.driver')   // 'memory'
const port   = store.get('app.port')       // 4000

// Load all files from a directory (what tekir() does internally)
await store.loadDir('./config')