Named & Typed Parameters with Default Values in TypeScript

Jeremy Brown
4 min read7 hours ago

--

A while back I started working on a new Electron + Vue + TypeScript project. Right away I anticipated that it would someday grow in complexity.

So… before writing any code and with my future programmer self in mind, I considered ways to standardized the code I was about to write, in order to make it more readable and maintainable.

In a recent Python project I was developing at my day job, I had been using a combination of type annotations, named keyword arguments and default values (where necessary). I found that calling functions with keyword arguments made the code more readable & understandable when would later revisit it.

This got me wondering if I could come up with a similar approach for functions in my TypeScript based project. Before we go further into the TypeScript solution, let’s take a look at how and why this is helpful in Python.

A Quick Python Example

Below is an example of a geometric Line class. The constructor for this class accepts two Point objects. It’s simple and demonstrates that the constructor is expecting two arguments of type Point and that if either is missing, default to a specific value.

class Line:
def __init__(self,
p1: Point=Point(x: 0, y: 0),
p2: Point=Point(x: 0, y: 0)):
self.p1 = p1
self.p2 = p2

# Create a line passing no arguments
l1 = Line()

# Create an instance of Line, supplying only the second point
l2 = Line(p2=Point(x: 0, y: 100))

# Create an instance of Line, defining both points
l3 = Line(p1=Point(x: 0, y: 0), p2=Point(x: 100, y: 100))

Compare this to the following that does not use type annotations, keyword arguments or default values.

class Line:
def __init__(self, p1, p2):
self.p1 = p1
self.p2 = p2

# This won't work as both arguments are required
l1 = Line()

# Also will not work for the same reason as l1
l2 = Line(Point(0, 100))

# Creates a new line
l3 = Line(Point(0, 0), Point(100, 100))

A case could be made that the second example is just as readable as the first and I would mostly agree with that. However, in the real world code is very often more complicated than these simple examples.

I’m sure that most readers would agree that it is common for functions to require several arguments of different types and that remembering the order of the arguments and their datatypes can be a challenge. With that in mind, let’s take a look at how we can implement this same approach with TypeScript.

The TypeScript Version

class Line {
p1: Point
p2: Point

constructor({
p1 = {x: 0, y: 0},
p2 = {x: 0, y: 0}
}: {
p1?: Point,
p2?: Point
} = {}) {
this.p1 = p1
this.p2 = p2
}
}

// Different ways to create a Line
const l1 = new Line() // Uses default arguments
const l2 = new Line({p1: {x: 0, y: 100}})
const l3 = new Line({
p1: new Point({x: 0, y: 0}),
p2: new Point({x: 0, y: 100})
})

Admittedly, we are trading brevity for clarity here… and in my opinion this syntax does take a while to begin to feel familiar. I think it’s worth the trade off though because with it we gain a lot of readability and more clear code.

There are a couple of tricky bits in this we should take a closer look at. Firstly, we are using object destructing to provide default values. For a better intuition of how this works, here is a simpler example:

const {x=2, y=4, z=8} = {x: 16, y: 32}
console.log(x, y, z) // Outputs: 16, 32, 8

Secondly, notice that we must use the ? before the colon to indicate which parameters are optional. Without this the TypeScript compiler will complain about missing parameters.

Another quirk you may have noticed is that p1 and p2 are defined as plain objects, each with an x and y property. This is acceptable because the signature of the object is compliant with the interface of the Point class. Meaning that {x: 0, y: 0} is a valid substitute for an instance of the Point class.

Lastly, we must recognize that the entire expression in the constructor is set equal to {}. How awkward is that? This oddity enables us to pass zero parameters to the constructor like in the creation of l1. It should be noted that we only want to include this if the function or constructor can be executed without any parameters (i.e. all parameters have default values).

Final Thoughts

I waited about six months after discovering this approach to write about it because I wanted to try it out thoroughly before sharing. Since then I have used it in nearly all of my code, however there are places where it makes less sense. For example, if a function has only one parameter, there is not much of a gain in readability by using it.

Of course, no article these days would be complete without some mention of ChatGPT. Here is what ChatGPT suggested when prompted with the following question:

How can I write a TypeScript constructor or function that accepts typed, keyword arguments with default values?

interface Options {
a?: number; // Optional, with a default value
b?: string; // Optional, with a default value
c?: boolean; // Optional, with a default value
}

class Example {
a: number;
b: string;
c: boolean;

constructor({ a = 42, b = "default", c = true }: Options = {}) {
this.a = a;
this.b = b;
this.c = c;
}
}

You can see that this is a very similar solution, with the addition of an interface defining the parameter list.

Well, that’s about it for this topic! Hope you found it interesting or insightful.

--

--

Jeremy Brown
Jeremy Brown

Written by Jeremy Brown

Software Engineer, originally from the U.S. and now living and working in Vienna, Austria. I write about Programming, Music, Machine Learning & NLP

No responses yet