Skip to content

English__Theory__TypeScript and wj config

Ramirez Vargas, José Pablo edited this page Nov 7, 2024 · 1 revision

TypeScript and wj-config

This project was the author's first-ever TypeScript-based project. As such, its TypeScript was not very good. As of v3.0.0, its TypeScript has been completely re-written. Now the Intellisense predictions on the final configuration object are 99% accurate. The new TypeScript in this package correctly:

  • Predicts the hierarchy of the configuration objects added with the addXXX() methods.
  • Predicts data type changes whenever a configuration source overrides a previously-existing property with a value of a different type.
  • Predicts the addition of the environment object, either as the default environment property name, or the specified custom property name.
  • Predicts the result of createUrlFunctions() correctly: Identifies the root object on all branches of the specified property or properties, the addition of all buildUrl() functions, the transformation of all string properties to URL-building functions and the exclusion of string properties that start with underscore (_).

The known 1% of the cases where TypeScript prediction might fail happens when conditional data source addition excludes data sources that contribute not-added-before properties to the configuration, or changes in the data type of an existing value.

For example, if the base configuration object defines property a: true, but a subsequent data source defines the same property with a type change (a: 1, so from Boolean to numeric), and this data source ends up not qualifying due to conditional addition, the type will incorrectly state a: number (property a is of type number), but in reality it ends up being of type boolean because the data source that redefined its type didn't apply.

The other case is the prediction of properties that don't really exist. If the conditioned data source is adding a new property b: string and ends up being excluded, TypeScript will predict the property exists but in reality it won't exist.

The potential failures might be mitigated further. If you have an idea on how to accomplish it, please, by all means open an issue and detail your proposal! It will be highly appreciated.

How to Use wj-config with the New TypeScript

The bulk of the work comes from the use of the addXXX() functions. These functions require the type of object that they are adding. The function typing takes care of merging the type with the inferred-so-far type of the final configuration object.

Typing while Adding Data Sources

Out of all addXXX() functions, only addJson() has no way of inferring the data type from its arguments and therefore the type must be always specified. Let's add one data source at a time and see how the configuration object grows with each data source.

Let's add JSON first:

import wjConfig, { buildEnvironment } from "wj-config";

async function getMyJsonConfig() {
    return await fetch('/somewhere').then(r => r.json());
}

type MyJsonConfig = {
    dynamic: {
        tier: string;
        restricted: boolean;
    }
};

const myDynamicJson = await getMyJsonConfig();

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .build();

At this point, VS Code will predict that the final configuration object will look identically to the JSON object obtained (since is the only data source added so far):

TypeScript with addJson()

Let's now add a custom data source with add():

NOTE: From this point forward, code snippets are meant to build upon the previous one and will show the newly added code only.

import wjConfig, { buildEnvironment, type IDataSource } from "wj-config";
import { DataSource } from "wj-config/sources":

type MyConfig = {
    custom: {
        p1: boolean;
        p2: number;
        p3: string;
    }
};

class CustomDataSource extends DataSource implements IDataSource<MyConfig> {
    constructor() {
        super('MyCustom');
    }
    getObject(): Promise<MyConfig> {
        return Promise.resolve({ custom: { p1: true, p2: 123, p3: 'Hi' } });
    }
}

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .build();

Now we have the new custom property:

TypeScript with add()

Let's import a JSON file now (which is a very common thing to do). The file contents first:

{
    "appName": "my-app",
    "appTitle": "My Application",
    "version": "1.0.0"
}

Now import the object and use addObject():

import baseConfig from './config.json' assert { type: 'json' };

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .build();

Now Intellisense says:

TypeScript with addObject()

Continuing with the example, let's add a fetched data source. This data source can only infer the configuration type if the processFn parameter is used.

type ExtraData = { e1: string; e2: number; };

async function fetchExtraConfig(r: Response) {
    const data = await r.json() as { extra: ExtraData };
    // Returning data.extra makes the properties e1 and e2 become root properties; returning data just adds the
    // extra property to the configuration object.
    return data;
}

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .build();

The first addition adds the e1 and e2 properties; the second one adds the extra property.

TypeScript with addFetched()

Single values are typed too:

const numProcessors = (() => 8)(); // Simulates detection of the number of processors

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .addSingleValue('arch:numProcessors', numProcessors)
    .addSingleValue(() => Promise.resolve(['arch:multiProcessor', numProcessors > 1] as const))
    .build();

The function overload of addSingleValue() is a bit tricky: The return value in the example is detected as a regular array and not a tuple. Adding as const corrects this. The result:

TypeScript with addSingleValue()

Only 2 data sources remaining. Let's add a dictionary data source:

const myDic = {
    'legacyConfig:propA': 'A',
    'legacyConfig:propB': 'B',
    'legacyConfig:propC': 'C',
};

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .addSingleValue('arch:numProcessors', numProcessors)
    .addSingleValue(() => Promise.resolve(['arch:multiProcessor', numProcessors > 1] as const))
    .addDictionary(myDic)
    .build();

This adds the legacyConfig property:

TypeScript with addDictionary()

For the cherry on top, let's add process.env as a data source:

NOTE: You need to install @types/node so globalThis.process is recognized.

type EnvDataSource = {
    'APP_db__server': string;
    'APP_db__username': string;
    'APP_db__password': string;
};

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .addSingleValue('arch:numProcessors', numProcessors)
    .addSingleValue(() => Promise.resolve(['arch:multiProcessor', numProcessors > 1] as const))
    .addDictionary(myDic)
    .addEnvironment(globalThis.process.env as EnvDataSource, 'APP_')
    .build();

As per the rules of the environment source, the above adds the db property with the 3 database-related properties for server name, username and password:

TypeScript with addEnvironment()

And what about addPerEnvironment()? Well, this one cannot tap an ear into the function's code that users write in it, so this one must also be typed explicitly, just like addJson(). See the next section for an example on it, because one cannot use it unless we first use includeEnvironment().

The Environment Object

The environment object dynamically provides environment testing functions based on the names of the possible environments:

const env = buildEnvironment('Staging', ['Development', 'Staging', 'PreProduction', 'Benchmarking', 'Production']);

Intellisense will predict the dynamic functions:

TypeScript for the environment object

This environment object can be included in the final configuration object:

const env = buildEnvironment('Staging', ['Development', 'Staging', 'PreProduction', 'Benchmarking', 'Production']);
export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .addSingleValue('arch:numProcessors', numProcessors)
    .addSingleValue(() => Promise.resolve(['arch:multiProcessor', numProcessors > 1] as const))
    .addDictionary(myDic)
    .addEnvironment(globalThis.process.env as EnvDataSource, 'APP_')
    .includeEnvironment(env, 'myEnv')
    .addPerEnvironment<{ db: { server: string; }}>((b, e) => {
        b.addFetched(`/config/overrides.${e}`) // No gains typing this one.
        return true;
    })
    .build();

Note the example also included addPerEnvironment() to complete the example on data sources.

TypeScript with includeEnvironment()

TypeScript for URL-Building Functions

URL-building functions is the one unique feature of wj-config that is not found anywhere else. No other configuration package provides this feature, and it is also fully typed.

Add this JSON file to the configuration:

{
    "api": {
        "rootPath": "/api",
        "timeout": 1000,
        "users": {
            "rootPath": "/users",
            "all": "",
            "byId": "/{userId}",
            "_adminUsername": "admin"
        },
        "orders": {
            "rootPath": "/orders",
            "forUser": "/users/{userId}/orders"
        }
    }
}
import apiConfig from './api.json' assert { type: 'json' };

export const config = await wjConfig()
    .addJson<MyJsonConfig>(myDynamicJson)
    .add(new CustomDataSource())
    .addObject(baseConfig)
    .addFetched<ExtraData>('/api/config')
    .addFetched('/api/config2', false, undefined, fetchExtraConfig)
    .addSingleValue('arch:numProcessors', numProcessors)
    .addSingleValue(() => Promise.resolve(['arch:multiProcessor', numProcessors > 1] as const))
    .addDictionary(myDic)
    .addEnvironment(globalThis.process.env as EnvDataSource, 'APP_')
    .includeEnvironment(env, 'myEnv')
    .addObject(apiConfig)
    .createUrlFunctions('api')
    .build();

Intellisense will show the buildUrl() function in every qualifying node, and will correctly estimate the transformed string properties:

TypeScript for createUrlFunctions

TypeScript for createUrlFunctions