iTranslated by AI
Bridging the Gap Between Class-Based and Functional Thinking in TypeScript
Class Constructors and Functions
class Person {
private name: string;
private age: number;
constructor(name: sring, age: number){
this.name = name;
this.age = age;
}
selfIntroduce(): string {
return `Hi! I'm ${this.name}!`;
}
isAdult(): boolean {
return this.age >= 18;
}
}
const person = new Person('takeshi', 20);
const introduce = person.selfIntroduce();
const isAdult = person.isAdult();
selfIntroduce doesn't use age, and isAdult doesn't use name.
If we treat the constructor as a dependency, it means each method has dependencies it doesn't use.
Therefore, let's turn this into one class per method.
class selfIntroduce {
private name: string;
constructor(name: sring){
this.name = name;
}
do(): string {
return `Hi! I'm ${this.name}!`;
}
}
const introduce = new selfIntroduce('takeshi').do();
class isAdult {
private age: number;
constructor(age: number){
this.age = age;
}
do(): boolean {
return this.age >= 18;
}
}
const isAdult = new isAdult(20).do();
Since it's a hassle to involve the constructor every time or to separate files (even though "one class per file" is a common practice), let's turn them into functions.
export const selfIntroduce = (name: string) => {
return `Hi! I'm ${this.name}!`;
}
export const isAdult = (age: number) => {
return this.age >= 18;
}
Builders and Function Currying in JS
Since JS can have functions as return values (functions are first-class objects), you can create a builder like the following.
/**
* Pass in hoge and fuga, then return them concatenated at the end.
*/
const hogeFugaBuilder = (hoge: string) => (fuga: string) => {
return `${hoge}${fuga}`;
}
// This has the same meaning. A function that returns a function.
const hogeFugaBuilder = (hoge: string) => {
return (fuga: string) => {
return `${hoge}${fuga}`;
}
}
// Usage
hogeFugaBuilder('hoge!')('fuga!') // hoge!fuga!;
Separate from this, you might think it's fine to just initialize everything at once.
const hogeFuga = (hoge: string, fuga: string) => {
return `${hoge}${fuga}`;
}
// Usage
hogeFuga('hoge!', 'fuga!') // hoge!fuga!
What is the difference between the hogeFugaBuilder function and the hogeFuga function? It is that the builder pattern allows for a time lag in dependencies. It can be used when hoge is decided immediately, but you want to input fuga later in the flow. Furthermore, it's easy to create a new function with the first dependency fixed. The fact that the function is viable as long as this first dependency is decided improves reusability and ease of handling.
const mochiFuga = hogeFugaBuilder('mochi');
const tamaFuga = hogeFugaBuilder('tama');
mochiFuga('fuga!!!') // mochifuga!!!
tamaFuga('fuga!!!') // tamafuga!!!
In functional languages, these are called currying, and all functions follow the builder pattern by default.
Class Decorators and Higher-Order Functions
Some languages have decorators for classes. TypeScript also supports decorators. They can receive, process, and return information about the decorated class or method, or record temporary variables in metadata.
import "reflect-metadata";
const formatMetadataKey = Symbol("format");
function format(formatString: string) {
return Reflect.metadata(formatMetadataKey, formatString);
}
function getFormat(target: any, propertyKey: string) {
return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}
class Greeter {
@format("Hello, %s")
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
let formatString = getFormat(this, "greeting");
return formatString.replace("%s", this.greeting);
}
}
The example is taken from the link above. The decorator above uses @format to hold the property value as metadata and getFormat to retrieve the value from metadata. It then rewrites it using the retrieved value.
const greeter = new Greeter('I am Tama-nya');
console.log(greeter.greet()); // Hello, I am Tama-nya
As a side note, even though it uses decorators, it seems like a waste that getFormat is called inside greet. I think the greet method would be more "pure" if getFormat were passed as an argument.
Let's try to reproduce this using functions.
Goal:
// Pure
const resGreet = greet('I am Tama-nya');
console.log(resGreet); // I am Tama-nya
// Format
const formatGreet = format("Hello, %s", greet);
const resFormatGreet = formatGreet('I am Tama-nya');
console.log(resFormatGreet); // Hello, I am Tama-nya
To digress again, when reproducing it as a function, you can see that in the format("Hello, %s", greet) part, the logic for how to format is intertwined with greet. The code would be easier to handle if the formatting logic could be separated, like format("Hello, %s", howFormatFunction, greet).
Getting back on track:
Let's convert the class to a function as in the first example. First, let's build it without considering decorators.
const greet = (message: string) => {
return message;
}
However, looking closely, the class example receives a format string in the greet method. We need a way to receive this, so let's rewrite it slightly.
const greet = (message: string, formatString?: string) => {
// If formatString exists, format; otherwise, return message as is.
return formatString
? formatString.replace("%s", message)
: message;
}
Thinking about the format function:
type GreetFunction = (message: string, formatString?: string) => string;
const format = (formatString: string, greet: GreetFunction) => {
return (message: string) => greet(message, formatString);
}
Note that it returns a new function with formatString pre-applied to the greet function.
This achieves our goal. Here is the code so far:
Observer Pattern, Data, and Pipes
One of the design patterns is the Observer pattern.
In a processing flow like Class A -> Class B and Class A -> Class C, instead of Class A calling methods from Class B and Class C, Class A fires an event when its processing is finished, and Class B and Class C detect that event to perform their own processing.
A benefit is that Class A doesn't need to know about Class B or Class C (conversely, Class B and Class C need a mechanism to detect the event), which prevents Class A from taking on too much responsibility and becoming a "God Class."
However, a drawback of the Observer pattern is that in a flow across multiple classes like Class A -> Class B -> Class C, it's difficult to grasp the overall flow. This is because the code for Class B detecting Class A's event and Class C detecting Class B's event is written in separate places.
In such cases, the concept of data and pipes is useful.
(Data)
|> Process 1
|> Process 2
|> Process 3
While classes bind data and behavior together, functions do not, which has led to the development of the "data and pipes" concept. The F# code above shows Process 1 through Process 3 being applied sequentially to the data source.
In TypeScript, unfortunately, it's not quite as clean, and you end up calling functions sequentially.
Process3(Process2(Process1(data)))
In Haskell, you can compose them using the dot operator.
Process3 . Process2 . Process1
The Context of Promise
The spread of Promises in JS has made code easier to write. They didn't just solve callback hell; if a function returns a Promise, it communicates two things:
- This method is asynchronous.
- This method has the potential to throw an exception.
Without Promises, you would have to rely on naming conventions or looking inside the code to know if a function was synchronous, asynchronous, or if it threw exceptions.
const res = someFunction();
These two contexts provided by a Promise make our lives easier.
Contexts can hold variables. In TypeScript, this is known as Generics.
type stringPromise = Promise<string>;
type numberPromise = Promise<number>;
type booleanPromise = Promise<boolean>;
In other words, we have become able to program the context itself.
While some aren't natively available in TypeScript, you can imagine other useful contexts:
- Possibility of failure
- Possibility of no value
- Asynchronous execution
- Occurrence of side effects
By the way, the term "Monad" often appears in functional programming; this is a tool to make programming within these same contexts easier.
Discussion