Mike Lischke
Mike Lischke

Reputation: 53347

Fake Operator Overloading

JS/TS does automatic boxing and unboxing of String, Number and Boolean types, which allows to use a mix of literals and objects in the same expression, without explicit conversion, like:

const a = "3" + new String("abc");

I'm trying to implement something similar for bigint and number by providing a custom class Long:

class Long {
    public constructor(private value: bigint | number) { }

    public valueOf(): bigint {
        return BigInt(this.value);
    }
}

const long = new Long(123);
console.log(456n + long);

This works pretty well (and prints 579n), but causes both, my linter and the TS compiler to show errors for the last expression. I can suppress them with comments like this:

// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
console.log(456n + long);

but that's not a good solution for entire apps.

Is there a way to tell that Long is to be treated as a bigint or anything else to avoid the errors?

About Why Doing That:

I'm working on a tool to convert Java to Typescript and want to support as many of the Java semantics as possible. The type Long holds a long integer, which is 64 bit wide, which can only be represented in TS by using bigint. The main problem with that is that Java automatically unboxes Long just like String and I want to support this semantic as far as I can.

For @caTS: so this will never be normal TS code but always used as java.lang.Long and hence there will be no confusion.

Upvotes: 3

Views: 272

Answers (2)

Mike Lischke
Mike Lischke

Reputation: 53347

Beside the problems @jcalz already mentioned there's a big issue with his solution: you cannot use any of the functionality of the Long class. For the compiler as well as ESLint Long is treated as the bigint primitive type due to that additional constructor assertion.

However, JS has a built-in primitive resolution approach, described on the Symbol.toPrimitive page. In my original approach I used one of the solutions (valueOf) to let JS automatically convert the Long class to the primitive bigint value, which preserves all the Long class functionality.

Unfortunately, using the valueOf or Symbol.toPrimitive still require an explicit step (invoking either numeric, string or primitive coercion), so it's only a semi-perfect solution. Here's the class using this approach:

class Long {
    public constructor(private value: number) { 
    }

    public valueOf(): number {
        return this.value;
    }

    private [Symbol.toPrimitive](hint: string) {
        if (hint === "number") {
            return this.value;
        } else if (hint === "string") {
            return this.toString();
        }

        return null;
    }
}

const long = new Long(123);
console.log(456 + +long);

(playground). Note the extra + in front of the long variable, which triggers the numeric coercion. As you can see I have not used bigint here, because that is treated in a different way. In the original code no numeric coercion was necessary to make it work (only error suppression). Using Symbol.toPrimitive with bigint does not work, however, even with numeric coercion. It's simply not implemented. A proposal to add that functionality was refused years ago.

So, in summary, for bigint there's no solution to my question (and bigint wasn't a good choice anyway). But for all other classes which can be coerced to a primitive type (either string or number) the mentioned approach works well, if you accept the explicit coercion.

Update

I played with the suggestions from @jcalz to use a separate intersection type for the Long class. Check this new playground example for details. While this works apparently, it still shows a number of typescript errors, which I would have to suppress. Essentially all static members raise a TS error.

Additionally, there are other problems with this solution, like the changed class name (which is no longer LONG) when using constructor.name (which I have to use for reflection emulation).

Upvotes: 0

jcalz
jcalz

Reputation: 328362

The behavior you want, whereby TypeScript allows you to use a custom class that overrides the Object.prototype.valueOf() method as if it were the primitive type returned by valueOf(), is unfortunately not part of the language. There's a fairly longstanding open feature request for it at microsoft/TypeScript#2361, but it has not been implemented, and it doesn't look like it will be implemented anytime soon.

For now, that means, there are only workarounds. One workaround you could use is to lie to the compiler that Long has a construct signature that returns a primitive instance. That is, you want it to have the type new (value: bigint | number) => bigint; (or perhaps new (value: bigint | number) => bigint & Long so that you keep any extra methods or functionality added to Long).

Here's how you could do that:

// rename
class _Long { 
    public constructor(private value: bigint | number) { }

    public valueOf(): bigint {
        return BigInt(this.value);
    }
}

// assign and assert
const Long = _Long as 
    new (value: bigint | number) => bigint & _Long;

Here I've renamed your original Long constructor out of the way, because once you declare class Long { } the value Long gets a constructor type automatically, and you can't change that type.

Then I assign the renamed constructor to the desired variable named Long, and asserted that it is of the desired type, new (value: bigint | number) => bigint & _Long instead of the actual type, new (value: bigint | number) => _Long.

I need to use a type assertion because the compiler would complain about a plain assignment; it knows that Long's instance type is not bigint.


Okay, so now we have a class constructor named Long that the compiler thinks produces bigint instances. Let's test that:

const long = new Long(123);
// const long: bigint
console.log(456n + long); // okay, 579

Looks good. I can call new Long(123) and the compiler thinks the result is a bigint. It also lets me use mathematical operators like + without complaint, and the result is what you expect.


So that works about as well as I can imagine. Still, I would generally not intentionally lying to the compiler, since such lies can trip you up later in weird ways. The type of long is observably not a bigint:

console.log(typeof long); // "object", not "bigint"

And so any operation that depends on long actually being a bigint could do funny things the compiler can't catch so you'll see unexpected runtime behavior:

const bigint1 = BigInt(4);
const bigint2 = BigInt(4);
console.log(bigint1 === bigint2); // true

const long1 = new Long(4);
const long2 = new Long(4);
console.log(long1 === long2); // false!

function copy<T extends {}>(x: T) {
    return (typeof x === "object") ? { ...x } : x
}

const bigint3 = copy(bigint1);
console.log(bigint3 + 1n) // 5

const long3 = copy(long1);
console.log(long3 + 1n) // "[object Object]1" 🤪

Now in the particular use case mentioned in the question, since all of the TypeScript code will be generated, you might be able to guarantee that you don't generate any code that trips over any of these stumbling blocks. But even so, it's important to be aware of these things and take them into account when deciding whether you want to proceed.

Playground link to code

Upvotes: 1

Related Questions