Tuner is a flexible project configurator as code for Deno.
Tags: deno, typescript
Published: 2023-10-21 16:52:25

I think I’m not the only one who has a need to conveniently configure a project. And there are many ready-made solutions of varying degrees of complexity and freshness. In this topic I want to demonstrate a module for defining project settings and managing them as code. I’ve been using Deno not long ago, but a number of features about which many articles have already been written (comparison with Node.js and Bun) turned out to be very convenient, but the Deno community is not yet sufficiently developed and there are also few modules available.

## Beginning of work
Let's create a _/config_ directory in the project root. All the work with the configuration and its processing will be done here. The Tuner will automatically locate a folder with this specific name and gather the config object from there. The configuration file itself should end with _.tuner.ts_.

```ts
// config/myConfig.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune(
  {
    config: {
      field1: 'value1',
      field2: 100,
      field3: true,
      field4: ['minimalistic', 'convenient', 'dont you think?'],
    },
  },
);
```

Inside the _tune_ function, the static analyzer will helpfully guide you that the configuration consists of two fields: _env_ and _config_. In the file whose path is specified in _deno.json_, you can create a config object, and you can associate it with an alias like _$global_, for example:

```ts
// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner.config.field2); // 100
```

After that, you can `import {tuner} from "$global"` in any file of the project and use it.

Upon execution, it is mandatory to have the _env_ variable _config_, its value being the name of the config file without _.tuner.ts_ extension. In this example, it is _myConfig_.

`config=myConfig deno run --allow-all main.ts`

In fact, this is the only required _env_ variable that needs to be passed into the project (or set different values for the _config_ variable in Doppler, if you use it).

## Tuning secrets
In Tuner, you have the option to describe the types of environment variables and specify their behavior in case they are absent:

- Default value
- Process termination
- Exception generation
- Computation on-the-fly
Here is a complete list of behaviors with different expected data types:

```ts
// config/myConfig.tuner.ts
import Tuner from 'https://deno.land/x/tuner/mod.ts';
export default Tuner.tune(
  {
    env: {
      // Use Default Value
      env1: Tuner.Env.getString.orDefault('default value1'),
      env2: Tuner.Env.getNumber.orDefault(100),
      env3: Tuner.Env.getBoolean.orDefault(true),
      // Ignore Absence of Variable
      env4: Tuner.Env.getString.orNothing(),
      env5: Tuner.Env.getNumber.orNothing(),
      env6: Tuner.Env.getBoolean.orNothing(),
      // Terminate Process
      env7: Tuner.Env.getString.orExit(
        'error message, optional',
      ),
      env8: Tuner.Env.getNumber.orExit(
        'will be printed to console before exit',
      ),
      env9: Tuner.Env.getBoolean.orExit(),
      // Generate Exception
      env10: Tuner.Env.getString.orThrow(new Error('error')),
      env11: Tuner.Env.getNumber.orThrow(new Error()),
      env12: Tuner.Env.getBoolean.orThrow(new Error()),
      // Compute Data Based on Provided Callback
      // (can be asynchronous, if data needs to be fetched from disk or remotely, for example)
      env13: Tuner.Env.getString.orCompute(() => 'computed value1'),
      env14: Tuner.Env.getNumber.orAsyncCompute(() =>
        new Promise(() => 100)
      ),
    },
    config: {
      field1: 'value1',
      field2: 100,
      field3: true,
      field4: ['minimalistic', 'convenient', 'isn\'t it?'],
    },
  },
);
```
> **Of course, you can simply specify a primitive value, for example, env1: 100.**

## We're building a hierarchy of configs

Sometimes a config consists of data that is not equally important and will need to be updated at different frequencies. To separate the 'core' data from the 'secondary', I recommend splitting the config into several mini-configs, creating a kind of hierarchy from them.

Tuner allows you to 'assemble' a config using other configs, you just need to build a chain:

- The current config will be supplemented with all the fields of the parent, while retaining its values.

- The current config will be supplemented with all the fields of the child, with matching fields being overwritten by values from the child config.

Function values used to describe env variables also follow these rules.


![An example of how inheritance works in Tuner](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t1m510zfrr135uzkh5n7.png)

Let's see how to do this in the code:

```ts
// config/develop.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  child: Tuner.Load.local.configDir('a.tuner.ts'),
  parent: Tuner.Load.local.configDir('base.tuner.ts'),
  config: {
    a: 300,
    b: 301,
  },
});


//config/base.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 400, b: 401, c: 402 },
});


//config/a.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
child: Tuner.Load.local.configDir('b.tuner.ts'),
  config: {
    b: 200,
    e: 201,
  },
});


//config/b.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 100, d: 101 },
});


//main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner);
//{ config: { a: 100, b: 200, c: 402, e: 201, d: 101 }, env: {} }
```
Tuner.Load provides several options for defining the source of the config. You can connect them locally, import them remotely, or request them through a provided callback


_Tuner.Load_ offers both local and remote ways to connect a config.

**Tuner.Load.local**

| Function                   | Returns the config object from the file by...                        |
| -------------------------- | -------------------------------------------------------------------- |
| absolutePath(path:string)  | ...specifying the full path to it.                                   |
| configDir(path:string)     | ...specifying the path relative to the directory named "config".      |
| cwd(path:string)           | ...specifying the path relative to the project directory.            |

---

**Tuner.Load.remote**

| Function                                                         | Description                                                                                     | Example (assuming the config file is located at http://some_server/b.tuner.ts)                |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| import(path:string)                                             | Works like a regular import.                                                                    | child: Tuner.Load.remote.import(”http://some_server/b.tuner.ts”)                                |
| callbackReturnModule(cb: () ⇒ Promise<{default: ITunerConfig}>) | Accepts a callback that returns a promise with the imported module.                            | child: Tuner.Load.remote.callbackReturnModule(() ⇒ import(”http://some_server/b.tuner.ts”))     |
| callbackReturnString((cb: () => Promise<string>))               | Accepts a callback that returns a promise with the module's code as a string (retrieve the config code from forms, blocks in Notion, etc.) | child: Tuner.Load.remote.callbackReturnString(() ⇒ someFetchingFunctionStringReturned(options: {…})) |

> Additionally, Tuner.Load.remote comes with built-in integrations with various services through Tuner.Load.remote.providers:
- notion(key:string, blockUrl:string) - provide the authorization key (use Tuner.getEnv to find the env variable in the environment or .env file) and the link to the block in Notion where the configuration module is described.
- github(key: string, owner: string, repo: string, filePath: string) - key, repository owner's username, repository name, and file path.
Some may find the idea of configuring a project through Notion strange... I understand that. But it was more convenient for my project, so this integration is available. If you have any suggestions, feel free to let me know, adding them is not difficult :)

## And the cherry on top - the config schema.

Working with the config would be unpleasant without a schema and displayed types.

For convenient development with the config object, it is recommended to generate a type for the object.

_Tuner.use.generateSchema_(**obj**: ObjectType, **variableName**: string, **filePath**: string) will create a file at the **filePath** with the schema of the **obj** object and export a type with the name **variableName**, capitalizing the first letter.

> You can separate the schema generation process into a separate task, so that, for example, deno task schema would be responsible solely for updating the schema.

Example:

```ts
const tuner = await Tuner.use.loadConfig();
Tuner.use.generateSchema(
  tuner,
  'config',
  'config/configSchema.ts',
);
```

We will see:

```ts
//config/configSchema.ts
import { z } from '<https://deno.land/x/zod/mod.ts>';
export const configSchema = z.object({
config: z.object({
a: z.number(),
b: z.number(),
c: z.number(),
e: z.number(),
d: z.number(),
}),
env: z.object({}),
});
export type Config = z.infer<typeof configSchema>;
//├─ config
//│  ├─ a
//│  ├─ b
//│  ├─ c
//│  ├─ e
//│  └─ d
//└─ env
```

Now we can complement the code snippet with the initialization of the config type for Tuner, and we will have a powerful and informative way to interact with our config:

```ts
// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
import {Config} from "config/configSchema.ts"
export const tuner = (await Tuner.use.loadConfig()) as Config;
```

## Conclusion

Of course, this is just an example of how to organize working with relatively constant configuration data. There is room for growth and expansion. 

A feature is already prepared in the form of a change observer (if a field in one of the configs is changed, an event or callback is triggered). However, in this version of Tuner (which I haven't released yet), there is a memory leak. Once I fix it, I'll release it to the Deno community. I would be grateful for any observations, constructive criticism, recommendations, and suggestions.

[![deno.land/x/tuner](https://shield.deno.dev/x/tuner)](https://deno.land/x/tuner)