All Blogs
🌞

Angular Three - an Inheritance story

Several days ago, I published a blog post highlighting a new inject() function that is coming along with Angular v14 and how inject() solves a pain point with Inheritance in Angular. Many people liked the post, but there were as many people who questioned ”why Inheritance?” or _”Composition over Inheritance”. _ I understand the curiosity of people who ask these questions because I am also a big proponent of Composition.

A tweet by Chau Tran shows he is an advocate for Composability (or Composition)

A tweet by Chau Tran shows he is an advocate for Composability (or Composition)

In this blog post, I will attempt to explain the use-case of **Inheritance **by using Angular Three, my library that allows easy integration with THREE.js.

Please read my blog post about the new inject() to understand the use-case of Inheritance. I’ll post the Inheritance snippet here to make this blog post easier to follow.

// 👇 we can annotate abstract class with empty Directive so Angular is aware of it
@Directive()
export abstract class Instance<TObject> {
    @Input() sharedInput = '';
    @Output() sharedOutput = new EventEmitter<TObject>();

    constructor(
        protected serviceOne: ServiceOne,
        protected serviceTwo: ServiceTwo
    ) {}

    ngOnInit() {
        /* shared init logic */
    }
}

@Component({/* component stuff */})
export class BoxComponent extends Instance<Box> {}

@Component({/* component stuff */})
export class PlaneComponent extends Instance<Plane> {}

Composition in Angular?

Composition is a more favored approach for sharing functionalities. In Angular, this can be achieved with Directives and Services.

I also cover the Service use-case in this blog post

Directive

There are mainly two types of Composition technique using Directive in Angular via Selector Matching

  • Unique Selector
  • Similar Selector

Unique Selector is straightforward. The basic concept is each Directive should have a unique selector that does not conflict with other entities’ selector (eg: other Directive or Component). Let’s rewrite the above Instance into a Directive

@Directive({
    selector: '[instance]'
})
export class Instance {
    @Input() instanceInput = '';

    // assuming at some point after init logic, we emit a new notification via this Subject
    instanceReady = new Subject();

    constructor(
        // 👇 notice we use "private" here instead of "protected"
        // 👇 this is because we don't need to derive any class from Instance
        private serviceOne: ServiceOne,
        private serviceTwo: ServiceTwo
    ) {}

    ngOnInit() {
        /* instance init logic */
    }
}

@Component({
    selector: 'box'
})
export class BoxComponent {
    @Output() ready = new EventEmitter<Box>();

                            //                    👇 we can inject the Instance here if used: <box instance></box>
                            // 👇 we need Optional() to safe-guard against when the consumer forgets to use [instance]
    constructor(@Optional() private instance: Instance) {
        // we can subscribe to "instanceReady" here at the earliest point
        instance?.instanceReady.subscribe(() => {
            this.ready.emit(/* ... */);
        })
    }
}

@Component({
    selector: 'plane'
})
export class PlaneComponent {
    /* similar to BoxComponent */
}

// consumer-land code
@Component({
    template: `
        <box instance [instanceInput]="" (ready)=""></box>
        <plane instance [instanceInput]="" (ready)=""></plane>
    `
})
export class ConsumerComponent {}

Assuming you read the blog post about Inheritance, you can see that using **Directive **solves some issues with the abstract class Instance approach, namely:

  • Changing Instance constructor no longer requires us to adjust Box or Plane code
  • Testing is straight-forward as Instance is a true Directive. We can use TestBed or just new Instance(mockedServiceOne, mockedServiceTwo) to test Instance

However, it also introduces different kinds of pain

  • Somewhat boilerplate-y to tie BoxComponent (or PlaneComponent) together with Instance via Dependency Injection. Not to mention, we need to clean up that instanceReady.subscribe() somehow.
  • What if BoxComponent, PlaneComponent, and Instance are all SCAMs (Single-Component as Module) and have their accompanying modules BoxModule, PlaneModule, and InstanceModule? Consumers will need to remember to import all of them or their code will not compile (IDEs might help out here)
  • What if BoxComponent and PlaneComponent always need Instance to be there? In other words, you cannot have a Box or a Plane without having an Instance

Now let’s look at Similar Selector. In contrast with Unique Selector, we can have Directives that share its selector with other Directive or Component.

@Directive({
    selector: 'box, plane', // either <box> or <plane> matches this Directive, hence, instantiating it
})
export class Instance {
    /* similar code */
}

@Component({
    selector: 'box'
})
export class BoxComponent {
    // similar code as above but we don't technically need @Optional() anymore
}

@Component({
    selector: 'plane'
})
export class PlaneComponent {
    // similar code as above but we don't technically need @Optional() anymore
}

// Consumer-land code
@Component({
    template: `
        <box [instanceInput]=""></box>
        <plane [instanceInput]=""></plane>
    ` 
})
export class ConsumerComponent {}

As you can see, we ensure Box and Plane will always have Instance logic by matching Instance selector with <box> and <plane>. This way, the consumer does not need to worry about using [instance] on <box> or <plane>. But, it introduces yet another problem in terms of Developer Experience (DX) for consumers who use an IDE that supports Auto-Importing Modules (eg: WebStorm)

Let’s go back to the assumption that BoxComponent and PlaneComponent were SCAMs. In order to use <box>, we import BoxModule and same thing applies to PlaneComponent (PlaneModule). Let’s also assume BoxModule exports InstanceModule so the consumer does not need to think about importing InstanceModule

@NgModule({
    declarations: [BoxComponent],
    exports: [BoxComponent, InstanceModule]
})
export class BoxModule {}

The first time the consumer uses <box> (or <plane>), WebStorm will be able to suggest an Auto-Import for BoxModule (or InstanceModule, because of Selector Matching). Then the moment the consumer uses <plane>, they will not get any suggestion because WebStorm already recognizes <plane> selector from Instance directive from InstanceModule.

That tiny (and maybe even outlier) DX aside, we might run into Testing issue with Similar Selector approach. As we add more selectors (to cover more shapes: sphere, cone, etc…), we might be required to test Instance directive with the new shapes’ selectors. And that pain point about repeating @Output() ready is pretty meh

We can move @Output() ready into Instance but then we’d lose type safety or we end up with some really hacky workaround (I’ve done it)

The above points cover what have been mostly my Composition usages in Angular. Please let me know if I miss anything substantial, I’d love to learn more patterns.

Next, let’s look at Angular Three

Basic Angular Three

I highly recommend reading through the Overview of Angular Three Documentation to have a brief understanding of it.

THREE.js is a highly imperative library with loads of Inheritance because it mainly deals with Object3D base classes. Typically, you construct your Scene Graph with a combination of Object3D

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube ); 

You can see that the flow of THREE.js is straight-forward and imperative.

  • You create a Scene
  • You create a BoxGeometry and a MeshBasicMaterial with a color
  • You create a Mesh with the geometry and material
  • You add the Mesh onto the Scene
  • And you would repeat this process to add more objects onto the Scene

For the purpose of this blog post about Inheritance, I think it is important to point out THREE.js’ inheritances

Scene -- is a (extends) --> Object3D
Mesh -- is a --> Object3D

- Object3D can interactive with each other. 
- Any Object3D can be any Object3D's parent
- An Object3D can have as many children which are Object3D as needed
- Object3D has a set of common properties that all Object3D has

BoxGeometry -- is a --> Geometry
MeshBasicMaterial -- is a --> Material 

- Geometry and Material know how to interact with their parent Object3D
- Geometry has a set of common properties that all Geometry has
- Material has a set of common properties that all Material as

The goal of Angular Three is the ability to express the Scene Graph in a more declarative way. Let’s take a look at an Angular template that does the same thing as the THREE.js’ counterpart but with Angular Three components:

<ngt-canvas>
    <ngt-mesh>
        <ngt-box-geometry></ngt-box-geometry>
        <ngt-mesh-basic-material color="#00ff00"></ngt-mesh-basic-material>
    </ngt-mesh>
</ngt-canvas>

To express the same level of inheritance that THREE.js has, Angular Three has the following:

NgtMesh is a  -- NgtCommonMesh --> is a --> NgtObject --> which wraps a --> Object3D
NgtBoxGeometry -- is a --> NgtGeometry -- which wraps a --> Geometry
NgtMeshBasicMaterial -- is a --> NgtMaterial -- which wraps a --> Material

In addition, Angular Three has NgtInstance which ALL entities should inherit. At this point, the question is Composition OR Inheritance. I have tried both

Composition

In version 4 and below of Angular Three, I used the Controller approach by the folks at TaigaUI to compose the THREE.js’ wrappers. Here’s a hierarchy of NgtMesh

NgtInstanceController (controls all Instances)
    NgtObjectController (controls all Objects)
        NgtCommonMeshController (controls all Meshes. eg: Mesh, InstancedMesh, SkinnedMesh)
            NgtMesh

NgtInstance, NgtObject, and NgtCommonMesh are provided via NgtMesh's providers

@Component({
    selector: 'ngt-mesh',
    providers: [
        provideCommonMesh(NgtMesh)
    ]
})
export class NgtMesh {
    /* an NgtMesh inputs are shared across NgtInstanceController, NgtObjectController, and NgtCommonMeshController */
}

Because an ngt-mesh CANNOT be rendered without it being an NgtObject, an NgtInstance, and an NgtCommonMesh, Similar Selector technique is needed, and I thought I had a pretty solid pattern for Angular Three

@Directive({
    selector: `
        ngt-mesh,
        ngt-instanced-mesh,
        ngt-skinned-mesh,
        ...
    `
})
export class NgtInstanceController {}
// same for NgtObjectController
// same for NgtCommonMeshController

Then the growing pain came. THREE.js has a lot of entities. Additionally, Angular Three exposes a set of abstractions over the core entities (via @angular-three/soba). These abstractions look something like this:

<!-- to construct a Cube, you'd do -->
<ngt-mesh>
    <ngt-box-geometry></ngt-box-geometry>
    <ngt-mesh-basic-material></ngt-mesh-basic-material>
</ngt-mesh>

<!-- soba provides <ngt-soba-box> -->
<ngt-soba-box>
    <ngt-mesh-basic-material></ngt-mesh-basic-material>
</ngt-soba-box>

These Soba components most of the times are NgtObject so the selector list of NgtObject keeps growing and growing

@Directive({
    selector: `
        ngt-primitive,
        ngt-bone,
        ngt-group,
        ngt-lod,
        ngt-points,
        ngt-mesh,
        ngt-instanced-mesh,
        ngt-skinned-mesh,
        ngt-audio,
        ngt-positional-audio,
        ngt-line,
        ngt-line-loop,
        ngt-line-segments,
        ngt-light-probe,
        ngt-ambient-light,
        ngt-ambient-light-probe,
        ngt-hemisphere-light,
        ngt-hemisphere-light-probe,
        ngt-directional-light,
        ngt-point-light,
        ngt-spot-light,
        ngt-rect-area-light,
        ngt-arrow-helper,
        ngt-axes-helper,
        ngt-box3-helper,
        ngt-grid-helper,
        ngt-plane-helper,
        ngt-polar-grid-helper,
        ngt-sprite,
        ngt-camera,
        ngt-perspective-camera,
        ngt-orthographic-camera,
        ngt-array-camera,
        ngt-stereo-camera,
        ngt-cube-camera
    `,
    exportAs: 'ngtObjectController',
    providers: [NGT_OBJECT_INPUTS_CONTROLLER_PROVIDER, NgtStore],
})
export class NgtObjectController {}

Angular Three’s consumers also suffer from the aforementioned DX problem with Auto-Importing. As soon as they auto-import one component that matches these Selectors, they cannot auto-import anymore component. That is painful. All kind of hacks were implemented to inherit Inputs, Outputs, and providing strongly-typed surface APIs. This was where I turned back to Inheritance

Inheritance

In v5 (current version) of Angular Three, I rewrite all controllers back to good ol’ Abstract Classes. The process was simple and the gain was noticeable. Angular Three’s core entities are easier to write, and all Inputs and Outputs on base classes are inherited with type safety

@Directive()
export abstract class NgtInstance<TInstance> {}

@Directive()
export abstract class NgtObject<TObject extends Object3D> extends NgtInstance<TObject> {}

@Directive()
export abstract class NgtCommonMesh<TMesh extends Mesh> extends NgtObject<TMesh> {}

@Component({
    selector: 'ngt-mesh',
    providers: [provideCommonMesh(NgtMesh)],
})
export class NgtMesh extends NgtCommonMesh<Mesh> {}

And best of all, the consumers can auto-import everything. Yes, I run into the same issues as any Inheritance based APIs and here’s one example

constructor(
    zone: NgZone,
    store: NgtStore,
    @Optional()
    @SkipSelf()
    @Inject(NGT_INSTANCE_REF)
    parentRef: AnyFunction<Ref>,
    @Optional()
    @SkipSelf()
    @Inject(NGT_INSTANCE_HOST_REF)
    parentHostRef: AnyFunction<Ref>,
    private textureLoader: NgtTextureLoader // this component only needs this
  ) {
        // but has to repeat all of these because the abstract base class needs them
    super(zone, store, parentRef, parentHostRef);
  }

Conclusion

I know the blog has gotten quite lengthy and I might not have been successful in making a case for Inheritance in Angular Three. After all, I’m pretty open to suggestions. If you read the whole blog and thought “I know of a better way”, please do let me know. I’m more than happy to sit with you and discuss. Other than that, I hope you learn something from the blog post. If you have any questions, feel free to ask. That said, I’ll see you in the next blog 👋

Published on May 21, 2022