Typescript: builder pattern that returns corresponding result type
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.
- Motivation: what is the problem? (in details)
- Ideas: what are the possible solutions?
- Solution: what the final solution looks like?
- 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
, b
and 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)