I have an application, which contains a logger. The logger already exists and is ready to be used, so it can be passed directly to the constructor.
class App {
private readonly logger: Logger;
public constructor(logger: Logger) {
this.logger = logger;
}
public run() {
// do stuff
}
}
function main() {
const logger = new Logger();
const app = new App(logger);
app.run();
return 0;
}
main();
Now I want to add configuration, so I can modify its behavior without having to rebuild the application (e.g., send requests to a certain URL without hardcoding the URL). I don't want my constructors to throw, though. I also want to be able to reload at runtime, so thats another restriction leading to a 2-phase init.
class App {
private readonly logger: Logger;
+ private readonly configManager: ConfigManager;
- public constructor(logger: Logger) {
+ public constructor(logger: Logger, configsDir: string) {
this.logger = logger;
+ this.configManager = new ConfigManager(configsDir);
}
+ public loadConfigs(configsDir: string) {
+ // It needs a separate initialization step, because reading files
+ // might fail and I don't want my constructors to throw
+ return this.configManager.loadConfigs();
+ }
public run() {
// do stuff
}
}
function main() {
const logger = new Logger();
- const app = new App(logger);
+ const app = new App(logger, "./run/configs");
+ if (!app.loadConfigs()) return 1;
app.run();
return 0;
}
main();
I have a widget/module/part that will make use of the configuration. Unfortunately, because the configuration uses a 2-phase init, it cannot go in the constructor, and must wait for the second init. And because the init is not in the constructor, it cannot be readonly
. Additionally, because Typescript cannot be certain that the variable is initialized, it must either be declared as T | undefined
or asserted to be non-null with the !
operator.
class App {
private readonly logger: Logger;
private readonly configManager: ConfigManager;
+ private widget!: Widget; // The exclamation mark asserts its definition
public constructor(logger: Logger, configsDir: string) {
this.logger = logger;
this.configManager = new ConfigManager(configsDir);
+ // Can't init the widget here, because the config has not loaded yet
+ // this.widget = new Widget(this.configManager.getWidgetSettings());
}
+ public loadWidget() {
+ // Because the init is not in the constructor, the variable cannot
+ // be `readonly`.
+ this.widget = new Widget(this.configManager.getWidgetSettings());
+ }
public loadConfigs(configsDir: string) {
// It needs a separate initialization step, because reading files
// might fail and I don't want my constructors to throw
return this.configManager.loadConfigs();
}
public run() {
// do stuff
}
}
function main() {
const logger = new Logger();
const app = new App(logger, "./run/configs");
if (!app.loadConfigs()) return 1;
+ app.loadWidget();
app.run();
return 0;
}
main();
Here I've gone with the assert route, since it looks nicer than throwing a bunch of if (!this.configManager) throw new Error()
s around. Then I need to pray that I remember to load everything and in the correct order.
What was originally a simple and straightforward "make the app run itself" turned into "pass everything in the constructor and run" and is now further degrading into a maze.
Is there a better design I can use to avoid all of the following:
- throwing constructors
- 2-phase initialization
- repetitive definition checks
- asserting
In any case, I will definitely catch mistakes since it crashes, but ideally I can avoid them entirely. I can't figure out a way to avoid all those at the same time, but maybe there's a less convoluted design I haven't thought of.
Result<TOk, TErr>
objects, with static "constructor methods" and private true constructors.