Specifying the type for Threejs Object3D.userData in TypeScript
Asked Answered
W

1

7

In Three.js there is a field on Object3D class called userData, which in the type declaration file node_modules/three/src/core/Object3D.d.ts is defined as

/**
 * Base class for scene graph objects
 */
export class Object3D extends EventDispatcher {

[...]

    /**
     * An object that can be used to store custom data about the Object3d. It should not hold references to functions as these will not be cloned.
     * @default {}
     */
    userData: { [key: string]: any };

[...]

}

I wanted to more strongly type the userData, so I created a module type declaration src/typings/three.d.ts:

declare module 'three' {
  export class Object3D {
    userData: MyType1 | MyType2;
  }
}

type MyType1 = {
  type: 'type1';
  radius: number;
};
type MyType2 = {
  type: 'type2';
  name: string;
};

This, while it did overwrite the userData property, it instead of merging, overwrote all type declarations in the three module, making the change more damaging than useful (note the lack of other properties). Missing type declarations in vscode

Is there a way to merge the type declarations in a way that only userData is overwritten and not entire module?

Winonawinonah answered 25/1, 2021 at 11:58 Comment(0)
C
4

In typescript jargon, what you're trying to do is "declaration merging" combined with "module augmentation" (see docs). There are a few problems with your sample so far, most of which is fixable, one of which is not (i.e. you've hit a limitation of the language).

I'm going to first explain these problems and (partial) solutions, and then suggest an alternative approach that will work completely.

  1. When you want to augment a class definition through module augmentation, your augmentation must be in the form of an un-exported interface, not an exported class definition. So instead of export class Object3D you must write interface Object3D. (Not the most intuitive thing, I know - but the docs state "Not all merges are allowed in TypeScript. Currently, classes can not merge with other classes or with variables.")
  2. You must match the signature of the class you are trying to augment exactly, including generic parameters and extends statements. So instead of...
    interface Object3D {
    
    ...you'd need to write...
    interface Object3D<E extends BaseEvent> extends EventDispatcher<E> {
    
    (hat tip to this previous answer for pointing out this poorly-documented "gotcha")
  3. The particular way that @types/three is written, it's not just one big file with "export class Object3D..." type statements at the top-level. Instead, the root file of @types/three has this statement:
    export * from './src/Three';
    
    In turn, src/Three had (among other things) this statement:
    export * from './core/Object3D';
    
    And the src/core/Object3D finally has the core class definition:
    export class Object3D<E extends BaseEvent = Event> extends EventDispatcher<E> {
    
    I've found through experimentation (although I can't find a clear explanation as to why) that these intermediate export * statements get in the way of your module augmentation code actually matching up with the thing it's trying to affect (e.g. the Object3D class definition in @types/three/src/core/Object3D). To get around this, your declare module statement must target that file specifically, not the re-exported version of it.

So putting all this together, this will (almost) work:

import { BaseEvent, EventDispatcher } from "three";

declare module "three/src/core/Object3D" {
  interface Object3D<E extends BaseEvent> extends EventDispatcher<E> {
    // This works fine...
    somethingElse: string;
    // However, this does not (see below)
    // Typescript will throw this error: 
    // Subsequent property declarations must have the same type.  Property 'userData' must be of type '{ [key: string]: any; }'
    userData: MyType1 | MyType2;
  }
}

This final problem is a limitation of the language - declaration merging cannot change the type of an existing property on the original class definition (even if the new type is technically a sub-type of the original one). It can only add new properties to the class.

An Alternative Approach That Does Work

Instead of using declaration merging, you can make a new class declaration that extends the original, and override the type there:

declare module "three" {
  import { BaseEvent } from "three/src/core/EventDispatcher";
  import { Object3D as Object3DOriginal } from "three/src/core/Object3D";
  export * from "three/src/Three";
  export class Object3D<E extends BaseEvent = Event> extends Object3DOriginal<
    E
  > {
    userData: MyType1 | MyType2;
  }
}

Check out this codesandbox for a working example.

Creation answered 27/4, 2022 at 17:23 Comment(1)
This solution works in simple cases, but doesn't work in most. For example, getObjectByName still returns the original Object3D type, not the overridden type. See codesandbox.io/s/…Ballon

© 2022 - 2024 — McMap. All rights reserved.