Typescript: builder pattern that returns corresponding result type

Kasama Chenkaow
3 min readMay 3, 2021

--

Recently I needed to build a little typescript library in my work, the team came up with builder-pattern idea which also brought a simple question like
“How do we get the correct type after we call .withA() .withB() and then .build()?”

Let me make it clear about what this blog is gonna talk about.

  1. Motivation: what is the problem? (in details)
  2. Ideas: what are the possible solutions?
  3. Solution: what the final solution looks like?
  4. Next step: what can I make it better?

Alright TS nerdy, let’s solve it together!

Motivation: what is the problem? (in details)

The first step to understand the problem is to consider the below code.

Looks pretty simple right?, the code builds the result with all the properties it has (a, band c) then eventually call the build function to generate the final result.

But what if we modify the code a bit to be like this.

As you guys see, the only difference is that I commented out the line .withB('b1') which makes the final result doesn’t contain b property at all but if you inspect the type of it, it still shows the possibility of having value of b !

Of course we can check the existence of b later but this is super simple example, imagine you have 10–20 properties and you call only withA and withC it must be better if the return type of your function is something like this right?

typeof result // { a?: string | undefined, c?: string | undefined }

So that it’s very convenient for the caller of your function since they only call .withA and .withB functions, they should be able to access only the respective properties.

Ideas: what is the possible solution?

In order to find if the solution is possible we need to really think what is the root cause of our problem

The root cause is that we always return the same Builder type in every withX function so the type of Builder['build'] function never get changed, or more specifically the ReturnType of Builder['build'] function never get adapted to the newest information

Now we know we gotta return the new ReturnType of the Builder['build'] every time we call withX by appending the X information into it right

We could also think about the mutation flow of the ReturnType like this.

{} -> withA() -> {a: string} -> withC() -> {a: string, c: string}

Or in another form

Result -> withA() -> Result & A -> withC() -> (Result & A) & C

Another point is the ReturnType of Builder['build'] function is currently static so we would need to make our Builder a generic type too

Solution: what the final solution looks like?

Based on the idea we got we need to modify our builder type a little bit like the below code

These are the major changes we did

line 6: Change the Builder type to be generic
line 7–9: Change all the withX function to return the new Builder with their own result type
line 13: Change the implementation according to the type and start putting the type unknown as builder’s initial type argument (since before calling any withX functions our result is empty)
line 39: Now the result type is correct!

At this point some of you might have a question like

Why in the type of withX I wrote the ReturnType to be Builder<TResult & ResultX>

withA: (a: string) => Builder<TResult & ResultA>

but in the real implementation I put only Builder<ResultX> as the ReturnType of it?

withA: function (a: string): Builder<ResultA>

The answer is unknown & T = T since unknown type has no constraints at all

Next step: what can I make it better?

The solution to solve now looks simpler than I initially thought so I’m thinking of making a super small library for this kind of builder pattern so that I could reuse it in the future and might help some people who doesn’t wanna deal with this puzzle too.

Alright, this is the way I solve it but if you guys have a better way please let me know, thank you!

(Edited: I also modified the final solution a bit here but didn’t want to put it directly into the article since I’m afraid it would be a bit confusing even though it’s more correct in my opinion)

--

--