-
Notifications
You must be signed in to change notification settings - Fork 2
English__Theory__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 allbuildUrl()
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.
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.
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):
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:
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:
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.
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:
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:
For the cherry on top, let's add process.env
as a data source:
NOTE: You need to install
@types/node
soglobalThis.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:
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 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:
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.
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:
Contents
- English
- Theory
- JavaScript Concepts
- Data Sources
- Español
- Teoría