barsdeveloper/ueblueprint

Suggestions for typescript

Opened this issue · 7 comments

Interesting project!

This is not really a issue, just some thoughts. As the code base is literally "Strongly typed JavaScript with JSDoc", have you considered using TypeScript instead? And if you have heard of TypeScript before, I'm pretty interested that why did you choose JSDoc+JS instead of TypeScript?

Hello and thanks for the interest shown in this project.
Yes I am aware of TypeScript but I didn't ever managed to learn the language properly. I started this project while I was working as a freelancer web designer and wanted to try some CSS so I decided to reproduce a blueprint node (to see if I can make it look similar). It turned out to be pretty good, then I wanted to try to add some other details to it, then other details and so on. At a certain point I wanted to see if I can make a graph like the one in UE. Now it feels and looks pretty much the same as the one from Unreal.

To reply your question, I didn't want to bother initially to learn TypeScript (even though I ended up learning JSDoc which is worse XD but it brings to the table more or less the same benefits as a type-related static analysis and refactoring tool) and didn't want to have an additional compilation step (which I ended up having anyway because I'm using Rollup and Sass). But let's say that I am somehow proud of this weird combination (Object Oriented JavaScript, Lit and JSDoc is pretty uncommon). At some point I will replace all the JSDoc with the types if/when they will make it into JavaScript https://github.com/tc39/proposal-type-annotations

It can be envisioned that this project will certainly get huge if it keeps being actively maintained. As the project get bigger, the benifits of TypeScript will become more significant. I think TypeScript will benefit large projects like this, and tried running ts-migrate on the project, but found that some patterns in the code base will lead to uninferrable types, such as automatic copying keys of attributes to instances of derived classes of IEntity.

Personally, I'm trying to bring the blueprint intepreter to the web(without UE specific functionalities), such as:

  • Doing pure calculations without UI
  • Calling DOM/BOM APIs, by providing custom node actions
  • Listening and triggering events, with CustomEvent node
  • etc...

Just before I'm trying to build UI from scratch, I found this project and was really impressed. I think switch to TypeScript as early as possible will make the collaboration easier for people interested in the project. The compilation process is not really a thing as recent frontend toolchains is really fast, such as vite or turbopack. I can help fiddling with these tools if you're interested in migration to TypeScript!

JSDoc has the same benefits as TypeScript already, just with a bit more convoluted comments syntax but with the added benefit of writing pure JavaScript code. Do you know anything that can be done in TS and not using JSDoc? Also the type annotation proposal did have a big success and it has a fair chance to make it into the standard, at that point JS will get much of the TypeScript syntax.

Moreover indeed IEntity is creating attributes in an object at runtime, depending on the object passed for initialization and I am not sure how such thing might be implemented in TypeScript. With JSDoc typing remains optional and any false positive error can be shut down at any time.

Do you know anything that can be done in TS and not using JSDoc?

For example, the is keyword. Take an example from Utility.js:

/**
 * @param {AnyValue} value
 * @param {AnyValueConstructor} type
 */
function isValueOfType(value, type, acceptNull = false) {
    return (acceptNull && value === null) || value instanceof type || value?.constructor === type
}

In TypeScript, this can be:

type ClassContructor = new (...args: any) => any;
function isValueOfType<T extends ClassConstructor>(value: any, _type: T, acceptNull = false): value is InstanceType<T> {
    return (acceptNull && value === null) || value instanceof _type || value?.constructor === _type
}
if (isValueOfType(obj, IEntity)) {
  obj.equals // no error, obj is IEntity
}

the type annotation proposal did have a big success and it has a fair chance to make it into the standard

I doubt the effeciency of tc39 and browser developers on large language features like this. (Decorator proposal was intially addressed on agenda in 2018, and only reached Stage 3 recently!)

Moreover indeed IEntity is creating attributes in an object at runtime, depending on the object passed for initialization and I am not sure how such thing might be implemented in TypeScript.

This is an example with TypeScript and legacy decorators. When decorator metadata reach stage 3, it can be switched to the new stage 3 decorator syntax at ease.

Using TypeScript is not a must, but we really benifits from it a lot at work. TypeScript has better IDE support, cleaner code base, and better refactoring tools. Also, any annoying type check errors can be shut down by the infamous as any at any time.

// @ts-check
/**
 * @template T
 * @param {T} value
 * @param {new () => T} type
 * @returns {T?}
 */
function isValueOfType(value, type, acceptNull = false) {
    return (acceptNull && value === null) || value instanceof type || value?.constructor === type
        ? value
        : null
}
class A {
    someMethod() {
    }
}
let a = new A()
isValueOfType(a, A)?.someMethod(); // No error, static analysis and refactoring supported

There is almost always a way, now I just need to rename that method to ifValueOfType or something similar.

TypeScript does add some benefits (maybe a few features also) but they are not really that critical, most of the things it adds it can be achieved through JSDoc leveraging VS Code type inference system that works for both TS and JS. To be fully honest I don't see it switching to TS (anytime soon). For now I am interested to do some general cleanup, fix as many bugs as I can and introduce some of the features that are still missing and then have a stable release and start implementing a VS Code extension.

Also please keep in mind that this library is developed specifically to visualize (and modify to some extent) UE Blueprint graphs. It may evolve in the future to support other visual languages but for now it is just this. If you are interested to integrate with some Blueprint interpreter library you are developing, it might be very interesting to do so and I can definitely help there, maybe create specific API entry points to get and provide information to manipulate the graph.

For now I will let this issue open in case someone else wants to put their 2 cents but I don't see it happening, sorry.

For example, the is keyword. Take an example from Utility.js:

In TypeScript, this can be:

type ClassContructor = new (...args: any) => any;
function isValueOfType<T extends ClassConstructor>(value: any, _type: T, acceptNull = false): value is InstanceType<T> {
    return (acceptNull && value === null) || value instanceof _type || value?.constructor === _type
}
if (isValueOfType(obj, IEntity)) {
  obj.equals // no error, obj is IEntity
}

I would like to expand a bit on that as my understanding of JSDoc has improved lately (and also more of it was implemented in VS Code).
InstanceType is implemented in the following way. Moreover the is directive can be used directly where JSDoc expects a type (at least in VS Code). This means now the exact same code works as shown here.

InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any

You can find it in a file called lib.es5.d.ts (/usr/share/code/resources/app/extensions/node_modules/typescript/lib/lib.es5.d.ts) depending on your setup.

/**
 * @template {new (...args: any) => any} C
 * @param {C} type
 * @returns {value is InstanceType<C>}
 */
function isValueOfType(value, type, acceptNull = false) {
    //...
    return true
}
function someF() {
    return /** @type {any} */(12)
}
class A {
    someMethod() {
    }
}
let a = someF()
if (isValueOfType(a, A)) {
    a.someMethod() // Correctly inferred to be of type A
} else {
    a... // No suggestion, a is any here
}

For those curious, this is how you translate such syntax to JSDoc. Basically you use the following patterns to translate it:
SomeType<T extends X> = Type becomes

/**
 * @template {X} T
 * @typedef {Type} SomeType
 */
/**
 * @template {abstract new (...args: any) => any} T
 * @typedef {T extends abstract new (...args: any) => infer R ? R : any} CustomInstanceType
 */
/**
 * @template {new (...args: any) => any} C
 * @param {C} type
 * @returns {value is CustomInstanceType<C>}
 */
function isValueOfType(value, type, acceptNull = false) {
    //...
    return true
}
if (isValueOfType(a, A)) {
    a.someMethod() // Correctly inferred to be of type A
} else {
    a... // No suggestion, a is any here
}

I am considering migrating the codebase to TypeScript. Before starting, there are some questions that need to be addressed, and I will use this issue to keep track of them.

The project has grown more than I initially intended, leading to a few large refactorings. On paper, the idea of having only JavaScript code seems to work well, and it does to some extent. However, as soon as changes are made, the whole setup can fall apart quickly. The main issue I encounter is that the language server alone is pretty ineffective from a couple of perspectives: one is signaling only useful errors, and the other is signaling all errors. I see many false positives that the language server fails to recognize as valid code. Generally, these involve things like accessing static variables through the constructor (someObj.constructor.staticVariable) or comparing an object with a string by letting the runtime automatically call the toString() method, and so on.

Another issue is that errors are only detected within the scope of the currently opened files (which makes sense for energy efficiency), but this approach does not cover all errors. To determine if some code causes an error elsewhere, one essentially has to open all the files. In general, I would not recommend this route for medium and large projects unless there is a way to systematically enforce type errors throughout the entire codebase. I suspect this might be possible, and I will investigate further.

Checklist:

  • Return anonymous classes extending an existing class with overridden properties (example: [IEntity.js#L102](IEntity.js#L102))
  • Get class name (example: IEntity.js#L48)
  • Access static properties from an instance
  • Get class object from an instance
  • Dynamic attributes that are defined in the parent class: only a subset of known attributes are defined and it allows unknown attributes (example: FunctionReferenceEntity)