Sergei Orlov's DEV Community Profile

Credit goes to: Clayton Cardinalli (Unsplash)

Factory arrow functions in TS

This article continues the discussion of using factory arrow functions and covers providing TypeScript type definitions for them. I highly recommend reading part I.

DISCLAIMER: The term factory arrow function is made up as I couldn't succeed in googling for a proper name for that. I avoid calling it a factory function because it is an existing term that means a different thing. If you know the correct name for the concept covered in this article, please, share it in a comment - I'd be glad to find out.

It's a Series

Type Inference

What we get for free with classes in TypeScript is that objects instantiated from those classes have type definitions out of the box. We can refer to the class itself as a type.

class Rectangle { public constructor( public length: number, public width: number, ) {} public getArea(): number { return this.length * this.width } } const r: Rectangle = new Rectangle(10, 20)

On the other hand, if we use a factory arrow function, the type of the returning object is going to be slightly more verbose.

const rectangle = (length: number, width: number) => ({ length, width, getArea: () => length * width, }) const r: { length: number; width: number; getArea: () => number; } = rectangle(10, 20)

The first thing we can do is declare an interface for our rectangle return type:

interface IRectangle { length: number width: number getArea: () => number } const rectangle = (length: number, width: number) => ({ length, width, getArea: () => length * width, }) const r: IRectangle = rectangle(10, 20)

We can also set IRectangle as a return type of our rectangle factory arrow function, but it will not be easy to identify it in the code. I prefer to put it right after declaring the variable for our factory arrow function so that it is visible at a glance.

interface IRectangle { length: number width: number getArea: () => number } type RectangleConstructor = (length: number, width: number) => IRectangle const rectangle: RectangleConstructor = ( length: number, width: number, ) => ({ length, width, getArea: () => length * width, }) const r = rectangle(10, 20)

Generic Factory Arrow Function Type

Now the type of our r is known and we don't need to specify it explicitly. But the type signature of our rectangle is very messy. Moreover, we'll have to use a similar type for all our factory arrow functions, so we should probably simplify it. We can create a generic type that will include both the arguments of the factory arrow function and the return type. Let's call it FAF for brevity.

type FAF<TArgs extends any[], TReturn> = (...args: TArgs) => TReturn

FAF accepts two types:

  • TArgs that will represent arguments of our function. It must be an array or a tuple. We'll make a small change to this type a bit later.
  • TReturn that will represent the return value of our FAF. A great benefit of using this type is that we can remove the types for the arguments safely as we define them in the generic FAF type. To me, the more types are inferred, the better for the developer. In this case, the whole function has no types defined except for the FAF itself.
type FAF<TArgs extends any[], TReturn> = (...args: TArgs) => TReturn interface IRectangle { length: number width: number getArea: () => number } const rectangle: FAF<[number, number], IRectangle> = (length, width) => ({ length, width, getArea: () => length * width, }) const r = rectangle(10, 20)

If we accidentally make a mistake and start accepting more arguments than what the type defines, we'll immediately see it. It doesn't save us from fewer arguments than we define in the tuple, but it's not much of an issue - if you don't need an argument, you can safely skip it. Another problem is that the FAF type is inconvenient if we use it for zero or one argument. We can fix it as follows:

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn

Instead of requiring an array or a tuple as our first type, we take the responsibility to check the provided type ourselves. If it is a tuple or an array, then we spread the type as a set of arguments. Otherwise, we refer to it as our function argument as is.

Now we don't have to care about adding the square brackets when we don't need them. If we create a FAF with no arguments at all, we can use the void keyword. In the following code snippet, rectangle has two arguments, square has one argument, and dot has no arguments, and in all cases, we don't have to care about specifying argument types anywhere but the FAF type.

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn interface IRectangle { length: number width: number getArea: () => number } interface ISquare { length: number getArea: () => number } interface IPoint { getArea: () => number } const rectangle: FAF<[number, number], IRectangle> = (length, width) => ({ length, width, getArea: () => length * width, }) const square: FAF<number, ISquare> = (length) => ({ length, getArea: () => length ** 2, }) const point: FAF<void, IPoint> = () => ({ getArea: () => 1, }) const r = rectangle(10, 20) const s = square(10) const p = point()

Keep in mind that we use tuples and arrays as our first type interchangeably, which means that we will have issues if we want to pass an array as our first argument, but avoid spreading. To do so, we can simply wrap it into square brackets:

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn const str: FAF<[string[]], string> = (strs: string[]) => ''

The I of SOLID

A joke about Solid Snake's eye goes here

Image taken from https://www.polygon.com

  • Interface Segregation Principle (ISP) suggests that we should prefer small interfaces to big interfaces. Apart from improved convenience of development, ISP allows us to follow the Law of Demeter (LoD), also known as the principle of least knowledge. LoD suggests that pieces of our code should have only limited knowledge about things they work with. One of the ways to follow ISP is by separating our types and building interface hierarchies. Following the knowledge term from the LoD, I prefer to name my interfaces as IKnowsX. For quite some time I also used the IXAware.

We can extract the getArea and length methods into separate interfaces. For now, we'll rely on the ability of TypeScript interfaces to extend from multiple other interfaces, and define the same types we had before as follows:

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn interface IKnowsGetArea { getArea: () => number } interface IKnowsLength { length: number } interface IRectangle extends IKnowsGetArea, IKnowsLength { width: number } interface ISquare extends IKnowsGetArea, IKnowsLength {} interface IPoint extends IKnowsGetArea {} const rectangle: FAF<[number, number], IRectangle> = (length, width) => ({ length, width, getArea: () => length * width, }) const square: FAF<number, ISquare> = (length) => ({ length, getArea: () => length ** 2, }) const point: FAF<void, IPoint> = () => ({ getArea: () => 1, })

Nothing really changed, but we reduced a bit of repetition.

Least Knowledge and Interface Composition

Back to LoD. Although extending interfaces may be useful in some cases, we can make our types as clever as we really need.

Let's split everything into the smallest pieces. First, we introduce separate interfaces for all the properties and methods. Of course, it's not mandatory to always split into one-field objects. Then, we amend our shape types. We'll make them barebone - by default, they will only require a minimal set of dimensions to be usable. But we will also make them generic so that we can define more features if we need them. Our Rectangle will be armed with getArea and getPerimeter whereas the square will remain barebone. Apart from providing us the flexibility in defining objects, this approach also makes destructuring easier. Pick<Axe> no longer required!

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn interface IKnowsGetArea { getArea: () => number } interface IKnowsGetPerimeter { getPerimeter: () => number } interface IKnowsLength { length: number } interface IKnowsWidth { width: number } type IRectangle<TFeatures extends Record<string, any> = {}> = IKnowsLength & IKnowsWidth & TFeatures type ISquare<TFeatures extends Record<string, any> = {}> = IKnowsLength & TFeatures const rectangle: FAF<[number, number], IRectangle<IKnowsGetArea & IKnowsGetPerimeter>> = ( length, width, ) => ({ length, width, getArea: () => length * width, getPerimeter: () => 2 * (length + width), }) const square: FAF<number, ISquare> = (length) => ({ length, }) const r = rectangle(10, 20) const s = square(10) const getLengthOf = (x: IKnowsLength) => x.length getLengthOf(r) // OK getLengthOf(s) // OK const getWidthOf = (x: IKnowsWidth) => x.width getWidthOf(r) // OK getWidthOf(s) // Argument of type 'ISquare<IKnowsGetArea>' is not assignable to parameter of type 'IKnowsWidth'. // Property 'width' is missing in type 'ISquare<IKnowsGetArea>' but required in type 'IKnowsWidth'. const getAreaOf = (x: IKnowsGetArea) => x.getArea() getAreaOf(r) // OK getAreaOf(s) // Argument of type 'IKnowsLength' is not assignable to parameter of type 'IKnowsGetArea'. // Property 'getArea' is missing in type 'IKnowsLength' but required in type 'IKnowsGetArea'. const getPerimeterOf = (x: IKnowsGetPerimeter) => x.getPerimeter() getPerimeterOf(r) // OK getPerimeterOf(s) // Argument of type 'IKnowsLength' is not assignable to parameter of type 'IKnowsGetPerimeter'. // Property 'getPerimeter' is missing in type 'IKnowsLength' but required in type 'IKnowsGetPerimeter'.

It is not mandatory to make the shapes generic. We could have made the features generic instead so that we can provide specific shapes that need those features. It is up to you to decide which approach to choose. If there are two shapes and twenty methods, it makes sense to make shapes generic. If it is vice versa... Well, you get the point. My rule of thumb is: don't waste time typing redundant letters. The total quantity of letters you can type throughout your life is not infinite. Here we have two shapes and four features so generalizing shapes sounds like two times less effort.

Static Methods

In TypeScript, we can define properties on a function because a function is an object. Thus, we can define an interface for a function and imitate static properties and methods on our types. Even more - we can just extend the interface from our FAF type!

type FAF<TArgs, TReturn> = TArgs extends any[] ? (...args: TArgs) => TReturn : (arg: TArgs) => TReturn interface IKnowsGetArea { getArea: () => number } interface IKnowsGetPerimeter { getPerimeter: () => number } interface ILengthAware { length: number } type ISquare<TFeatures extends Record<string, any> = {}> = ILengthAware & TFeatures interface ISquareFAF extends FAF<number, ISquare<IKnowsGetArea & IKnowsGetPerimeter>> { new: ISquareFAF } const Square: ISquareFAF = (length) => ({ length, getArea: () => length ** 2, getPerimeter: () => 4 * length, }) Square.new = Square const s = Square.new(10) // <- Looks like Rust! Square.new.new.new.new.new.new(10) // <- Looks like Insanity!

Conclusion

In this article we covered using factory arrow functions in TypeScript. I hope you enjoyed reading it!


Subscribe to the newsletter!

I don't have comments set up on my blog. But I'd be glad to hear your thoughts on what you've just read. If you would like to discuss something, we can have a chat on Twitter.