Programming: Static or not static?
this.isTheQuestion
In the beginning, everything was static. The only way to program was to use pointers jumping from one memory address to another, and data was accessed from such addresses, found in CPU registries.
mov ax, [1234h] ; Load from fixed memory address into AX
add ax, 1 ; Increment AX
mov [1236h], ax ; Store into another fixed address
jmp 0100h ; Jump to absolute address (static control flow)Then came labels, an abstraction of those addresses, that could be referenced by a name. This allowed executing code or accessing data through routines and variables names, respectively. But none of them had a scope: they were all “global” variables, as we call them today. Or had they?
mov ax, [data1] ; Load value from variable name
add ax, 1 ; Increment
mov [data2], ax ; Store into another variable
jmp routine ; Jump using a label (named routine)And the same idea was applied to higher-level languages:
LET A = 1
GOSUB PRINTVAL
END
PRINTVAL: PRINT A : RETURNThe rise of scopes
When such routines became procedures with parameters, those arguments were considered “local” variables, that would only live (have an allocated address) for the time of the execution: they were accessible only for the scope of this procedure:
procedure IncAndPrint(x: integer);
begin
writeln(x + 1);
end;This was the first scoping (using the stack), but the procedures — or functions, for the returning-value flavor — were still themselves in the global scope.
Then came modules (or packages, or units, depending on the language) to allow better organization by scoping those variables and functions inside them: the same identifier (variable or function name) could be used in different modules, each with its own different implementation and semantics:
unit MathStuff;
var Value: integer;
procedure DoIt; begin writeln('Math=', Value); end;
end.program Demo;
uses MathStuff;
begin
Value := 42;
DoIt;
end.However, you can only have one instance of a module. Even today, with JavaScript modules:
export let value = 0;
export function setValue(newValue) {
value = newValue; // Only the module itself may reassign its own binding
}
export function doIt() {
console.log("Math=", value);
}import { value, setValue, doIt } from "./MathStuff.js";
// value = 42 would throw: imported bindings are read-only
setValue(42);
doIt(); // "Math= 42"What modules brought was a form of encapsulation: scoped code and even a state (set of variables). But business often requires the ability to replicate the same model with different states.
This can be implemented through data structures like records (or even arrays):
let account1 = { owner: "Alice", balance: 0 };
let account2 = { owner: "Bob", balance: 0 };
account1.balance += 100;
account2.balance += 50;
console.log(account1.balance, account2.balance); // 100 50Then some found it more convenient to bind them with custom business code, through objects. All of them were instances of a model (or prototype, or “class” for objects) that provided a dynamic scope (created by the new keyword):
class Account {
constructor(owner) { this.owner = owner; this.balance = 0; }
deposit(amount) { this.balance += amount; }
}
let a1 = new Account("Alice")
let a2 = new Account("Bob");
a1.deposit(100);
a2.deposit(50);
console.log(a1.balance, a2.balance); // 100 50So it sounds like static is the opposite of dynamic: it is about code and data structure that are not bound to an instance. It looks like it’s about code that executes without context.
There’s always a context
But even nothing is something, as you always start from somewhere. So we should rather consider static context as some root, system context. For example, some languages allow having static code executed as soon as the class code is loaded. In such a case, that static code context is the class itself, along with its static members:
class Document {
static instancesCount: number
static { // Static code block
Document.instancesCount = loadPersistentInstancesCount()
}
}So yes, there’s always a context. Even a program can be started multiple times, leading to multiple instances (of a process) executing the same code (provided it doesn’t stick to the same exclusive resources, such as ports):
staticFunction(process.env.SOME_VAR)Then, if static is not about context, what is it?
Movement is relative
As the word itself suggests, static is about things that don’t move; understand: that don’t change. Indeed, every context/scope can have its variable and invariable (constant) parts.
In a few words, static is about the invariant part of something, and is a relative concept: a scope can be viewed as static locally, but considered as dynamic (hosted in a new instance, typically) from a calling context.
Aside a whole program process, another illustration of this relativity is that you can have static members of dynamic instances. They are static relative to the class:
class Document {
static format = new Format("article")
constructor() {
console.log("My format is", Document.format)
}
}
const doc = new Document() // Logs "My format is …"As you can see, most often you’ll have to specify the scope of static things you want to use: you can’t use format because this will be assumed to be a variable from the (implicit) global scope, and so will have to use Document.format.
Module scope
Since static things are context-relative, they are not always in your current “global” scope. If defined in modules, typically, you’ll have to explicitly import them:
// mymodule.js
export function myFunction(param) {
// compute
return result
}
export const myValue = 12import * as myModule from "mymodule.js"
myModule.myFunction(param1)
let startValue = myModule.myValueWe described modules as a good way to provide scoping, but this doesn’t guarantee that you can’t have name collisions in the import scope, as two modules can export the same identifier:
// Module1
export function init() {}// Module2
export function init() {}Fortunately, there’s a way to distinguish them:
import {init} from "Module1"
import {init as init2} from "Module2"
init()
init2()Global scope
OK, but at least global variables are available from anywhere, right? Yes and no: at runtime, var (or globalThis.xxx) declarations will be found, but your IDE will consider them as undeclared. When using TypeScript, you’ll need to either import or declare them:
// MyModule
declare var myGlobal; // Declare to the compiler that the var exists
export function myFunction(param) {
// compute
return result + myGlobal
}import * as MyModule from "MyModule"
globalThis.myGlobal = 12 // A module-scoped var would not be visible there
MyModule.myFunction(param1) // Will use value of myGlobalA bit risky, isn’t it? As there is no guarantee that the myGlobal will be assigned by the caller of the module function. Paradoxically, declaring “trust me there will be a myGlobal var in the global scope” lacks static checking.
The choice
o in the end it looks like static versus dynamic is not about preferences of programming style, but about supporting change or not: it may be easier to use static things… as long as they don’t change.
For sure a number of things seem inherently static: constants, those business data that you’re sure “will be unique” 🤞 or those configurations and business rules that “will never be instantiated differently” 🍀:
// MyModule
export class DocumentService {
static get(name) {
const handle = DocumentService.find(name)
const contents = DocumentService.read(handle)
return new DocumentImpl(contents)
}
protected static find(name) {
// ...
return handle
}
protected static read(handle) {
//
return contents
}
}const doc = DocumentService.read("someDoc")The problem is that it is just an assumption:
- maybe that factory doesn’t require any configuration at the start of the project, but will eventually require one; who knows?
- what if you want to override some part of the code?
As soon as you need to build code that is more flexible and doesn’t rely on such assumptions, you will create an instance of it.
// MyModule
export class MyFactory {
constructor(protected config: Config) {
}
create(params) {
if (this.config.impl) {
return new MyImpl(params)
} else {
return new MyOtherImpl(params)
}
}
}import {MyFactory} from "MyModule"
const fact = new MyFactory(config)
const obj = fact.create(params)However one might argue that there is a risk of instantiating multiple factories in the case you want to make sure there is only one. This can be worked around by the traditional singleton pattern:
// MyModule
export class MyFactory {
protected static singleton: MyFactory
// Protected to prevent instantiation from outside
protected constructor(protected config: Config) {
}
static getInstance(config: Config): MyFactory {
if (!MyFactory.singleton) {
MyFactory.singleton = new MyFactory(config)
}
return MyFactory.singleton
}
}import {MyFactory} from "MyModule"
const fact = MyFactory.getInstance(config)
const obj = fact.create(params)That way, you are:
- future proof : whether your object will need a state or not, callers won’t be affected ;
- safe : you guarantee a single instance.
Not mockable
Fine, but my static method has no state to fake anyway.
That objection sounds reasonable, and it is exactly where the confusion sits. Mocking is not about state, it is about substitution : a test needs a place where it can put something else in front of the real collaborator. Call that place a seam.
Get Jérôme Beau’s stories in your inbox
Join Medium for free to get updates from this writer.
Math.max() is static, and perfectly testable: give it numbers, check the number that comes back. It has no collaborator, so there is nothing to substitute. Now compare:
class ArticleExporter {
export(name) {
const doc = DocumentService.get(name) // Reads the real file system
return this.render(doc)
}
}There is no seam here. DocumentService is named at the call site, so a test of export() has exactly two options: hit the real file system, or reach for whatever module-patching magic your test runner provides — that is, work around your own design. This can be refactored as:
class ArticleExporter {
constructor(protected docService: DocumentSource) {
}
export(name) {
const doc = this.docService.get(name) // Whatever we were handed
return this.render(doc)
}
}
const exporter = new ArticleExporter(new FakeDocumentSource())What was gained:
- testable : the collaborator arrives through the constructor, so a test can pass a fake one ;
- explicit : reading the signature is enough to know what the class needs ;
- unchanged in production : the caller that has nothing to fake just passes the real one.
So it is not statelessness that makes static code hard to test, it is the missing seam. A static function with no dependency is fine forever. A static function that names another one has hard-coded its collaborator, and no test will talk you out of it.
A non-contractual API
An interface is a promise about who answers at a call site. A static call has already decided.
interface DocumentSource {
get(name: string): Article
}
class DocumentService implements DocumentSource { // Fine
get(name: string): Article { return articleOf(name) }
}
class StaticDocumentService {
static get(name: string): Article { return articleOf(name) } // Implements nothing
}implements only ever constrains instance members, in TypeScript as in Java: statics are not part of the type an interface describes. The same goes for abstract — there is nothing to defer to a subclass, because there is no subclass in the picture at the moment of the call.
But statics are inherited, and I can override them.
True, and that deserves a fair hearing. In JavaScript, static members are inherited through the prototype chain, and this inside a static method is the class itself, so this works:
class Reader {
static handleClass = FileHandle
static open(name) {
return new this.handleClass(name) // this is the class, so a subclass can swap it
}
}
class TestReader extends Reader {
static handleClass = FakeHandle
}Still, look at what it costs to use: the caller has to name TestReader instead of Reader. The polymorphism is real, but it is resolved by the caller’s source code , not by what the caller was handed. That is not a contract, that is a hard-coded choice with extra steps.
Hidden dependencies
class MyClass {
doSomething(params) {
OtherClass.doSomethingStatic(params) // Nobody outside can tell we need OtherClass
}
}
const myObject = new MyClass()
myObject.doSomething(params)A user of MyClass might not realize you need OtherClass. One may argue that makes sense since they are implementation details, but that can also have significant impacts. Instead just allow providing/injecting dependencies, even if a single instance:
class MyClass {
constructor(protected other: OtherClass) {
}
doSomething(params) {
this.other.doSomething()
}
}
const myOther = new OtherClass(params)
const myObject = new MyClass(myOther)
myObject.doSomething(params)Security
Last but not least: it’s easier to access static data than dynamic data. I don’t need to locate and access a SecretGenerator instance to set SecretGenerator.prefix.
Use cases
There are some cases where static can be justified.
Instances counters
Since it is only about instances, there is no risk of being wrong by counting them at their class level:
class MyClass {
static instancesCount = 0
constructor(params) {
MyClass.instancesCount++
}
}Utility functions
class MyUtils {
static readContents(path: string): string {
}
static objToString(obj): string {}
}A utility is static because it has nothing to be relative to: give it its arguments and it answers, today and in six months. That is the whole test — does this function have a collaborator it did not receive as a parameter?
objToString(obj) does not. readContents(path) already does, quietly: the file system. Which is why the first one will still be static in ten years, and the second one will one day have to read from an archive, or a cache, or a bucket… and that day you will want an instance.
But not factories. A factory’s job is to choose an implementation, so it depends on whatever drives that choice: a configuration, an environment, a feature flag. Making it static freezes that choice into every call site, which is precisely the decision you wanted to keep open.
Singletons
A very common use of static instances is to implement a singleton:
export class MyClass {
static readonly instance = new MyClass()
protected constructor() {
// Disallow creating other instances
}
}Here you’re assuming that there will always be a single instance of something in… in what? The app, the VM, the machine, or just in a hosting object? Does this unicity allow easy testing? You’d better double check that.
Another problem with staticity is that it becomes very inconvenient if you need to initialize the instance with some parameters that would come from… non-static code. Then you find yourself juggling with lazy accessors and so on, thus making your code more complex and error-prone.
A more flexible option is to scope your singleton to a class, then decide if you really need a single instance of that class or not.
// Not exported, so not instantiable from outside the module
class MyClass {
constructor() {
}
}
export const instance = new MyClass()And how do I test it, if the class is not exported?
You don’t, not in isolation: the module hands your test the same instance it hands production, and that is the trade-off you accepted when you decided there would be only one. If it hurts, that is information — export the class as well and let the tests build their own , or admit that the unicity was a wish rather than a requirement.
Preparing valid constructor arguments
Here is the one static I write without arguing with myself: a named constructor.
A constructor has a single name, and it must accept whatever it is given. As soon as an object can be built from two different kinds of input, you end up with this:
class Duration {
constructor(ms?: number, seconds?: number, label?: string) {
// Sort out what the caller actually meant
}
}
new Duration(undefined, 90) // What am I even reading?Every parameter is optional, so every combination compiles, including the ones that mean nothing. This can be refactored as:
class Duration {
protected constructor(readonly ms: number, readonly label: string) {
}
static ofMilliseconds(ms: number) {
return new Duration(ms, ms + "ms")
}
static ofSeconds(seconds: number) {
return new Duration(seconds * 1000, seconds + "s")
}
}
const timeout = Duration.ofSeconds(90)What was gained:
- no optional state : the constructor takes exactly what the object needs, and every field is final ;
- named intent :
ofSecondssays at the call site what(undefined, 90)did not ; - one place to validate : whatever is refused is refused before the object exists.
Note what this static does not do: it holds no state, it has no collaborator, and it does not decide which class to build — it only prepares arguments for its own. The moment it starts choosing an implementation, it is a factory, and it belongs in an instance.
The best of both worlds
There is a way to benefit from both the convenient static API and the ability to customize behavior of an API instance: provide both:
export class DocumentService {
constructor(args) {
}
get(name) {
const handle = this.find(name)
const contents = this.read(handle)
return new DocumentImpl(contents)
}
protected find(name) {
// ...
return handle
}
protected read(handle) {
// ...
return contents
}
}
export const docService = new DocumentService(defaultArgs)You can now import that instance exactly as you would import a class, and you use it as is: nothing to construct, nothing to configure. It is ready to use, already set up with its default arguments:
import { docService } from "./DocumentService"
const doc = docService.get("someDoc") // Nothing to set up, ready to use And the class is still exported next to it, so you can build other instances whenever you need one — as the application evolves and requires a different configuration, or in tests that have to simulate another construction context:
const customDocService = new DocumentService(otherArgs) // Still possibleConclusion
Static code is bad, static instances are OK. Don’t confuse those needs.
A class depending on a static method has tight coupling (…) it nearly always leads to code that is difficult to test. Ideally, a class should not be responsible for obtaining its dependencies, and if possible, they should be externally injected.









