#How can I type an abstract base class and abstract base builder so that they work together?

36 messages · Page 1 of 1 (latest)

dusty saddle
#

I'm trying to define an abstract base class, and then define an abstract builder from that. I'd like to then extend the Base/Builder in combination with specific subclasses but I keep running into type errors.

When I try and define the builder as just Builder<TBase extends Base<TBase>> I can't figure how to provide the correct typings for clone. However when I TBuilder extends Builder<TBase, TBuilder> as a generic type to the builder I can't figure how to translate that to the type of builder the token receives as a constructor.

I know I have to be going wrong somewhere -- or maybe this is just not possible in TypeScript.

bold creekBOT
#
bbuck#0

Preview:```ts
abstract class Base<TBase extends Base<TBase>> {
// how can I fix this? Is there a better pattern?
constructor(builder: Builder<TBase>) {}
}

abstract class Builder<
TBase extends Base<TBase>,
TBuilder extends Builder<TBase, TBuilder>

{
abstract clone(): TBuilder
...```

brittle agate
#

generally builders do the building, they aren't passed to the constructor

dusty saddle
#

This is a pretty standard builder pattern in other typed languages.

#

If the builder did all the building I’d either need to expose properties on the base class publicly, or have the constructor take all the values in some way. Neither approach feels ideal. I’m trying to restrict object creation to the builder and discourage accessing the properties of the built class directly.

#

The actual use case is a Token class that has a method to sign and return a JWT.

#

I can expand the example code if it helps.

violet gazelle
#

Show some actual usage of the builder and the class itself.

#

FYI, builder pattern is a pattern used in heavy OOP languages to enable "passing information around of partial instructions to construct an object" because those languages cannot live without classes and they don't have a way to represent partial information. This issue does not exist TS in because of object/array literals and the pattern is for the most part completely unnecessary.

dusty saddle
#

This is not production code, but the pattern is similar enough to what I want to implement to be demonstrative.

#

I appreciate any and all help/advice. I'm trying to resolve a type issue, I'm not specifically asking about whether or not the builder pattern is something you find useful in JS/TS. I'm open to alternatives you suggest (link to examples/patterns), but I don't really find "builder isn't for TS" responses helpful. Otherwise, this is the pattern I'd like to use and if possible I'd love to get the type issues resolved. Mostly to learn more about TypeScript typings.

#

(reposting example code with selection)

bold creekBOT
#
bbuck#0

Preview:```ts
...
const baseBuilder = new UserTokenBuilder().withSecret(
environment.secret
)

const users: {id: string; token?: string}[] = []
for (const user of users) {
user.token = baseBuilder
.clone()
.withUserId(user.id)
.build()
.toJwt()
}```

violet gazelle
#

@dusty saddle See below

bold creekBOT
#
nonspicyburrito#0

Preview:```ts
// fake some stuff

const jwtLib = {} as {
sign: (claims: Record<string, unknown>, secret: string) => string;
}

const environment = {} as {
secret: string;
}

// abstract defs

abstract class Token {
protected readonly secret: string;

constructor(builder: Builder<Token>) {
...```

violet gazelle
#

The pattern of Foo<T extends Foo<T>> is not necessary in TS, because subclasses can override signature to provide a more concrete type, as long as it is still assignable to the base type.

#

I'm not specifically asking about whether or not the builder pattern is something you find useful in JS/TS. I'm open to alternatives you suggest (link to examples/patterns), but I don't really find "builder isn't for TS" responses helpful
If you would like, I can elaborate on why builder pattern is unnecessary in TS.

dusty saddle
#

Sure! I’m happy to explore alternatives. Chances are I’m familiar with what you may suggest but also, maybe not.

violet gazelle
#

I presume you mostly have a background in a different language than TS, and it's an OOP language?

dusty saddle
#

No. I’ve spent a lot of time in JS/TS and have used several other languages as well.

#

Professionally I’ve worked in (order of YOE): JavaScript (Coffee, Type, etc.), Ruby, Java, PHP. Outside of work I’ve built things in Go, Elixir, Haskell, C/C++, and I’ve managed to piss off the Rust compiler a few times.

violet gazelle
#

Okay, I had the feeling that you had a background in mostly OOP languages because well, both patterns you've used here (Foo<T extends Foo<T>> and builder) are patterns that are typically used in run of the mill OOP languages. I assumed wrong then.

#

Either way, patterns exist to solve problems, so to talk about those patterns we first need to know what problems they solve.

#

I'm going to use C# and TS as the two languages in comparison because those are the two I write the most code in, and just so happen that the two patterns you used are necessary in C#, but unnecessary in TS.

dusty saddle
#

I only used the nested type because I’m most certainly sure I ran into issues without it but maybe not. It was EoD yesterday and I was tired so chances are I didn’t try everything I could think off. I did keep getting an error that a class that extended a subclass couldn’t be assign to a generic field with the extension restriction because “it could have been initialized with any type”

#

I forgot to mention C# but I’m also familiar with that too. Did my internship many years ago in it and also with Unity.

#

Oh and also probably because I was restricting the constructor parameter “a builder of this token type.” I’m on a phone now so haven’t poked at your playground other than reading it over but it looks like it may allow any token builder in a constructor, even to other token implementations. But some of that is easier to find in review than always pushing into the type system with convoluted types.

violet gazelle
#

Let's start with Foo<T extends Foo<T>>. What problem does this solve?
In C#, let's say we want a BaseFoo class with a Clone method to clone itself. Intuitively let's write:

abstract class BaseFoo
{
    public abstract BaseFoo Clone();
}

class Foo : BaseFoo
{
    public override BaseFoo Clone() => new Foo();
    public void DoFooStuff() {}
}

Now the problem arises: ideally Foo.Clone should return a Foo not a BaseFoo, but we can't do that because the language doesn't allow us to change the method signature. This leads to problems like:

new Foo().DoFooStuff();
new Foo().Clone().DoFooStuff();
//                ^^^^^^^^^^
// Foo.Clone still returns BaseFoo

So how do we solve it? The problem is that we can't change the base class method signature, so we must pass the information of the subclass to the base class somehow. The way to do that is by using the Foo<T extends Foo<T>> pattern:

abstract class BaseFoo<T> where T : BaseFoo<T>
{
    public abstract T Clone();
}

class Foo : BaseFoo<Foo>
{
    public override Foo Clone() => new Foo();
    public void DoFooStuff() {}
}

Perfect, now we successfully passed the subclass information back to base class, and new Foo().Clone().DoFooStuff(); correctly works.
Now that we understand the problem and how the pattern solves it, let's look at TS instead:

abstract class BaseFoo {
    abstract clone(): BaseFoo
}

class Foo extends BaseFoo {
    clone() {
        return new Foo()
    }
}

And it just works. This is because TS allows subclass to overwrite the signature (whereas C# does not), so long as the subclass' signature still conforms to the base class'. So in TS, there's no reason to employ the Foo<T extends Foo<T>> pattern, because the issue does not exist, we can simply just overwrite the signature and that's it.

#

Now let's talk about the builder pattern. What problem does it solve?
In C#, let's say we want to construct a user object which requires 3 pieces of information, but the process is split into 3 methods. Naively we might write some code like:

User CreateUser()
{
    var id = "new-user-id";
    return CreateUserStep2(id);
}

User CreateUserStep2(string id)
{
    var name = "Foobar";
    return CreateUserStep3(id, name);
}

User CreateUserStep3(string id, string name)
{
    var isAlive = true;
    return new User(id, name, isAlive);
}

This works, but this code is extremely fragile. Imagine if later we changed id to a GUID instead of a string, now you need to update all of the CreateUserStep* methods.
Instead, let's use a builder:

User CreateUser()
{
    var builder = new UserBuilder();
    builder.SetId("new-user-id");
    return CreateUserStep2(builder);
}

User CreateUserStep2(UserBuilder builder)
{
    builder.SetName("Foobar");
    return CreateUserStep3(builder);
}

User CreateUserStep3(UserBuilder builder)
{
    builder.SetIsAlive(true);
    return builder.Build();
}

Perfect, now if id changes type, we only need to change the builder itself and the specific step that creates the id, while other steps are completely unaffected. Our code is now much more refactor friendly.
However that's not the end yet. When CreateUserStep3 receives a builder, it has no way to know whether the other properties have been set or not, and thus calling .Build() might not be safe. Let's further refactor it:

interface IUserBuilderStep1
{
    IUserBuilderStep2 SetId(string id);
}

interface IUserBuilderStep2
{
    IUserBuilderStep3 SetName(string name);
}

interface IUserBuilderStep3
{
    IUserBuilderCompleted SetIsAlive(bool isAlive);
}

interface IUserBuilderCompleted
{
    User Build();
}

Now we can guarantee the builders are called in order, and nothing is missed when building.

#

Fundamentally, what problem does this solve? This solves the problem of "how do we pass partial information around without repeating it over and over." C# simply does not have a language feature to facilitate passing partial information around like this, that's why we need a separate builder class to house all the information, and use these interfaces to restrict access until we are done.

#

TS however, does not have this problem, you have ways to pass information around and provide strong types for them:

type X = { foo: string, bar: number }
const x: X = { foo: 'yep', bar: 42 }

Now how do we use this instead of builder pattern?

type UserOptions = { id: string, name: string, isAlive: boolean }
type UserOptionsStep1 = Pick<UserOptions, 'id'>
type UserOptionsStep2 = UserOptionsStep1 & Pick<UserOptions, 'name'>

function createUser() {
    const id = 'new-user-id'
    return createUserStep2({ id })
}

function createUserStep2(options: UserOptionsStep1) {
    const name = 'Foobar'
    return createUserStep3({ ...options, name })
}

function createUserStep3(options: UserOptionsStep2) {
    const isAlive = true
    return new User({ ...options, isAlive })
}

That's it. Strongly typed, zero builder boilerplate, perfectly solves the "passing partial user information around" problem.

violet gazelle
dusty saddle
#

I picked the builder pattern because of the problems it solves. Not just trying out patterns to be cool. Thanks for your help/response.

violet gazelle
#

Not saying you did, but just trying to explain why the problem builder pattern solves does not exist in TS so it's unnecessary.

dusty saddle
#

Your example is a limited (in flexibility) approximation of the builder pattern you can’t say “it doesn’t exist” while also demonstrating that it solves a problem. And you’ve made constant, irritating, assumptions about what I know and why I chose what I did. I’m grateful for your help on the type issue I asked about but I didn’t come here seeking opinions on the merit of one pattern or another.

violet gazelle