Re-typing Parent Class Attributes in TypeScript

I was recently working on converting some code away from Backbone.js and toward Spina, our TypeScript Backbone “successor” used in Review Board, and needed to override a type from a parent class.

(I’ll talk about why we still choose to use Backbone-based code another time.)

We basically had this situation:

class BaseClass {
    summary: string | (() => string) = 'BaseClass thing doer';
    description: string | (() => string);
}

class MySubclass extends BaseClass {
    get summary(): string {
        return 'MySubclass thing doer';
    }

    // We'll just make this a standard function, for demo purposes.
    description(): string {
        return 'MySubclass does a thing!';
    }
}

TypeScript doesn’t like that so much:

Class 'BaseClass' defines instance member property 'summary', but extended class 'MySubclass' defines it as an accessor.

Class 'BaseClass' defines instance member property 'description', but extended class 'MySubclass' defines it as instance member function.

Clearly it doesn’t want me to override these members, even though one of the allowed values is a callable returning a string! Which is what we wrote, darnit!!

So what’s going on here?

How ES6 class members work

If you’re coming from another language, you might expect members defined on the class to be class members. For example, you might think you could access BaseClass.summary directly, but you’d be wrong, because these are instance members.

In other words, the above code actually executes as:

class BaseClass {
    constructor() {
        this.summary = 'BaseClass thing doer';

        /*
         * Note that description isn't set here, because we only
         * typed it above. We didn't give it a value.
         */
    }
}

So our types above say “this can be a string or a function, but it belongs to the instance and will be set when constructing the instance.”

Our MySubclass.summary and MySubclass.description don’t really exist on the class either. They exist on the prototype as MySubclass.prototype.summary and MySubclass.prototype.description, and this is where we get into trouble.

Members on the instance takes precedence over members on the prototype.

We can test this:

const instance = new MySubclass();

// This will print "BaseClass thing doer"
console.log(instance.summary);

// This will print 'MySubclass does a thing!'
console.log(instance.description())

Wait, hold up a sec. Why did that work for description()? Well, because BaseClass may have typed it, but it didn’t set it. So there was no instance property overriding our method.

Here’s what we know so far

  1. Members on a class (aside from methods and accessors) are instance members, created when the class is constructed.
  2. Methods and accessors are prototype members.
  3. Instance members always override prototype members (they’re checked before the prototype).
  4. Because of this, TypeScript rightly complains when we try to override instance members with prototype members.

Sadly, there’s no way to say “I’m typing this as a prototype member.” We can define a method in the base class, but we can’t say “protoype method or value.” So what is one to do?

If we’re careful…

Before we continue: Our situation’s a bit more complex than all this, and we’re doing some magic behind the scenes to make everything play nice. Some of you will be cringing a little here, but bear in mind, a lot of this is best applied when marrying the ES6 class design, prototype design, and TypeScript, in an effort to create a stable foundation.

Okay, let’s make some rules:

  1. Base classes can define typed value-or-callable members, but they cannot set them to defaults (we means we won’t give summary a default value above).
  2. Callers must always check if the value is a callable instead of assuming it’s a value (e.g., use _.result(obj, attrName))
  3. Subclasses may define these as methods, but they have to opt in for the typing.

We can’t just define a new type on the subclass. We have to first get rid of the typing on the parent, dynamically.

Ideally we would do this:

class MySubclass extends Omit<BaseClass, 'summary' | 'description'> {
    ...
}

Ah, but that gives us:

'Omit' only refers to a type, but is being used as a value here.

We can’t apply types to a parent class name. We have to give it a value, in the form of a typed function. Here’s how we’ll do that:

type Class<T = {}> = new (...args: any[]) => T;

function OmitClass<
    T extends Class,
    K extends keyof InstanceType<T>
>(
    ParentClass: T,
    ...keys: [K, ...K[]]
): Class<Omit<InstanceType<T>, K>> {
    return ParentClass as any;
}

// Which compiles down to:
function OmitClass(ParentClass, ...keys) {
    return ParentClass;
}

We’re defining a function that takes in the class type and variable arguments of key names as parameters. It then returns the provided class with those keys omitted.

Here’s how we use it:

class MySubclass extends OmitClass(BaseClass, 'summary', 'description') {
    get summary(): string {
        return 'MySubclass thing doer';
    }

    description(): string {
        return 'MySubclass does a thing!';
    }
}

And hey, that works! No type errors.

TypeScript will auto-fill in those generics based on what we provided, as well, which keeps this from being too lengthy (though… that depends on how much you have to override).

All together now!

class BaseClass {
    summary: string | (() => string);
    description: string | (() => string);
}

type Class<T = {}> = new (...args: any[]) => T;

function OmitClass<
    T extends Class,
    K extends keyof InstanceType<T>
>(
    ParentClass: T,
    ...keys: [K, ...K[]]
): Class<Omit<InstanceType<T>, K>> {
    return ParentClass as any;
}


class MySubclass extends OmitClass(BaseClass, 'summary', 'description') {
    get summary(): string {
        return 'MySubclass thing doer';
    }

    description(): string {
        return 'MySubclass does a thing!';
    }
}


const instance = new MySubclass();

// This prints "MySubclass thing doer"
console.log(_.result(instance, 'summary'))

// This prints "MySubclass does a thing!"
console.log(_.result(instance, 'description'))

It works!

This feels like a hack

Yeah and it is. There are some compromises here, and some design choices that have to be made. But until/unless JavaScript or TypeScript gains formal support for setting and typing prototype members inside the class definition, interfacing with existing prototype-based code can require tradeoffs.

At least now you know some rules and tricks to help keep this manageable. And, hopefully, create a stronger foundation for new code to grow.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top