microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.11k stars 12.5k forks source link

Allow wrapped values to be used in place of primitives. #2361

Open icholy opened 9 years ago

icholy commented 9 years ago
class NumberWrapper {
    constructor(private value: number) {}
    valueOf(): number { return this.value; }
}

var x = new NumberWrapper(1);

// The right-hand side of an arithmetic operation 
// must be of type 'any', 'number' or an enum type.
console.log(2 + x);

It would be nice if an arithmetic operation could allow using the wrapped number because it's valueOf() method returns the expected primitive type.

This can be generalized to: if type T is expected, then a value that implements the following interface can be used.

interface Wrapped<T> {
    valueOf(): T;
}
danquirk commented 9 years ago

What specific use cases do you have in mind here?

icholy commented 9 years ago

@danquirk this

RyanCavanaugh commented 9 years ago

We had a similar discussion a long time ago -- that the rules for the math operators should be written in terms of the valueOf members of the apparent type of the operands rather than their types. It wouldn't be a breaking change and would enable this and some other good scenarios. I believe the only issue there was the behavior of Date values.

icholy commented 9 years ago

We had a similar discussion a long time ago

Is there a public thread somewhere I can check out?

I believe the only issue there was the behavior of Date values.

What issue are you referring to?

RyanCavanaugh commented 9 years ago

It was an internal discussion before we went public.

Date is insane and I think why we backed off from doing this. Its valueOf method produces a number, but its [[DefaultValue]] internal method defaults to a string hint type (http://www.ecma-international.org/ecma-262/5.1/#sec-8.12.8). This results in very surprising behavior:

> var x = new Date();
undefined
> x.valueOf()
1426616370842
> x + x
"Tue Mar 17 2015 11:19:30 GMT-0700 (Pacific Daylight Time)Tue Mar 17 2015 11:19:30 GMT-0700 (Pacific Daylight Time)"
> x - x
0
> x + 0
"Tue Mar 17 2015 11:19:30 GMT-0700 (Pacific Daylight Time)0"
> x - 0
1426616370842
> x * x / x
1426616370842
> x * x + x
"2.0352342695543988e+24Tue Mar 17 2015 11:19:30 GMT-0700 (Pacific Daylight Time)"

:cry:

icholy commented 9 years ago

Oh man ... I didn't know that.

icholy commented 9 years ago

What if Date was treated as a special case? Either don't allow it, or have the compiler yell at you.

RyanCavanaugh commented 9 years ago

Would like to see this as e.g. a change to the spec + implementation that specifies exactly how this might work

Zorgatone commented 9 years ago

I need this one 👍

demurgos commented 8 years ago

Hi, I am currently contributing to the typings project and I encountered this issue when writing definitions for the big-integer npm package. This package allows you to use arithmetic on their wrapper object trough implicit calls to toJSNumber thanks to valueOf. Here is their documentation.

This is just an other use-case for this issue and I'm hoping that there will be a solution.

icholy commented 7 years ago

@RyanCavanaugh I noticed that in the es6 typings, the Date interface has [Symbol.toPrimitive](hint: "default") declared. That should let us do something like this.

type BoxedValue<T> = {
  valueOf(): T;
}

type DefaultValue<T> = {
  [Symbol.toPrimitive]?(hint: "default"): T;
}

type Operand<T> = T | BoxedValue<T>;
type StrictOperand<T> = T | (BoxedValue<T> & DefaultValue<T>);

4.19.1 The *, /, %, –, <<, >>, >>>, &, ^, and | operators

These operators require their operands to be of type Any, the Operand type, or an enum type. Operands of an enum type are treated as having the primitive type Number. If one operand is the null or undefined value, it is treated as having the type of the other operand. The result is always of the Number primitive type.

Any Boolean Operand\<Number> String Other
Any Number Number
Boolean
Operand\<Number> Number Number
String
Other

4.19.2 The + operator

The binary + operator requires both operands to be of the StrictOperand type or an enum type, or at least one of the operands to be of type Any or a Operand type. Operands of an enum type are treated as having the primitive type Number. If one operand is the null or undefined value, it is treated as having the type of the other operand. If both operands are of the Number primitive type, the result is of the Number primitive type. If one or both operands are of the String primitive type, the result is of the String primitive type. Otherwise, the result is of type Any.

Any Boolean StrictOperand\<String> Operand\<String> Other
Any Any Any Any String Any
Boolean Any String
StrictOperand\<Number> Any Number String
Operand\<String> String String String String String
Other Any String
icholy commented 7 years ago

@RyanCavanaugh I did (an extremely hacky) implementation and it does seem to work https://github.com/Microsoft/TypeScript/compare/master...icholy:master

icholy commented 7 years ago

In my own code I'll probably start using something like this:

class Time extends Date {
  [Symbol.toPrimitive](hint: "default"): number;
  [Symbol.toPrimitive](hint: string): string | number {
    switch (hint) {
      case "number":
        return this.valueOf();
      case "string":
        return this.toString();
      default:
        return this.valueOf();
    }
  }
}
omidkrad commented 6 years ago

Another use case we're looking at is MoneySafe.

Its type definition is like below:

declare module 'moneysafe' {

  export const m$: (options?: {
    centsPerDollar: number,
    decimals: number,
    symbol: string,
    round: (x: number) => number,
  }) => Money;

  export const $: MoneyFunc;
  export const in$: (cents: number) => number;

  interface MoneyFunc {
    (dollars: number): Money;
    of: (cents: number) => Money;
    cents: (cents: number) => Money;
  }

  interface Money {
    valueOf(): number;
    cents: number;
    $: number;
    round(): Money
    add(a$: number): Money;
    subtract(a$: number): Money;
    toString(): string;
  }

}

And here's how it's used:

import { $ } from 'moneysafe';
let cents = $(.2) + $(.3); // TS error: Operator '+' cannot be applied to types 'Money' and 'Money'.
console.log(cents); // Works at runtime. Outputs: 50
MagicPro1994 commented 6 years ago

@omidkrad : My case is similar to yours. I was forced to used "any". It's not good at all.

sdrsdr commented 6 years ago

If you could add a extra diagnostics line in the compiler output before the error with the meaning "to get this done use .valueOf()" and everybody is happy?

omidkrad commented 6 years ago

@sdrsdr no, consider this code:

var startTime: Date = null; // read it from db and it was null
var duration = (new Date() - startTime) / 1000;
if (duration > 10) {
  console.log('do something');
}

If startTime comes as null then above code won't break, but if you use startTime.valueOf() or startTime.getTime() in the calculation then it will throw error.

Uncaught TypeError: Cannot read property 'valueOf' of null

So we have to add null checks everywhere to avoid the error. Besides, adding unnecessary valueOf() everywhere will make formulas (such as moneysafe calculations) ugly and hard to read. I think it would be best if TypeScript just allows this, because it is valid JavaScript.

sdrsdr commented 6 years ago

Not handling nulls from outside world is an error in itself. This particular code will be better handled with Date.now() a single static call than constructor plus a hiden .valueOf() that calls .getTime(). I grew a huge dislike to automagical typecasting since my C++ years.

Arcath commented 6 years ago

Has anything happened on this?

I have a class of Unit that when used as a string e.g. width:${width} returns width:10px; and using valueOf should be mathable e.g. width * 2 // 20. This works fine as plain JS but not as TS due to Unit not being any, number or enum.

Is there a way to type this so it works? I've tried @icholy's approach with the [Symbol.toPrimative] typing to no avail.

icholy commented 6 years ago

@Arcath the [Symbol.toPrimative] would fix the issue with Date if this feature was ever added.

mateja176 commented 5 years ago

typescript-extra is a library ( brand new ) that hopes to provide users with extra types like

You name it! As well as presenting an easy way of creating which ever new type you might need. If this is not enough to convince you, check out the readme.

As with BigInt the users of typescript-extra are forced to used valueOf to explicitly get the number primitive which is wrapped in an object e.g.

1 + new Int(1).valueOf()

Even though the code effortlessly compiles without the use of valueOf

1 + new Int(1)
GarkGarcia commented 5 years ago

@RyanCavanaugh What exactly constitutes a proposal? How specific do I have to be? Anyway...

I propose that, as stated by Ryan, the rules for the math operators should be written in terms of the valueOf members of the apparent type of the operands rather than their type.

This means that arithmetic expressions would be allowed for the number, bigint, string, any types and any other type that has a valueOf member whose return value is of type number, bigint or string. The type's internal [[DefaultValue]] is disregarded on this matter (for types other than number, bigint, string and any, of-course).

If a both operands of a mathematical expression satisfy this condition, but their respective valueOf return types are of incompatible numeric types (number and bigint) the operation is not allowed.

I would advice for the conversion to be executed at the transpiling level, to avoid any issues with [[DefaultValue]]. For example, x + 4 would transpile to x.valueOf() + 4.

HerbCaudill commented 5 years ago

@RyanCavanaugh this strikes me as a bug. TypeScript shouldn't choke on valid JavaScript. It seems weird that this issue is still open after 4 years, especially considering that the community has provided multiple use cases, proof-of-concept code, and a specification.

HerbCaudill commented 5 years ago

FWIW the use case that has led me here is the Counter class in Automerge, which looks something like this:

class Counter {
  constructor(value) {
    this.value = value || 0
    Object.freeze(this)
  }

  /**
   * A peculiar JavaScript language feature from its early days: if the object
   * `x` has a `valueOf()` method that returns a number, you can use numerical
   * operators on the object `x` directly, such as `x + 1` or `x < 4`.
   * This method is also called when coercing a value to a string by
   * concatenating it with another string, as in `x + ''`.
   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/valueOf
   */
  valueOf() {
    return this.value
  }

  toString() {
    return this.valueOf().toString()
  }

  increment(delta) {
    delta = typeof delta === 'number' ? delta : 1
    this.value += delta
    return this.value
  }

  decrement(delta) {
    return this.increment(typeof delta === 'number' ? -delta : -1)
  }
}

TypeScript chokes on any use of a Counter object as a number, even though the code executes as expected:

const c = new Counter(3)
c.increment()
assert.strictEqual(c < 4, true) // Operator '<' cannot be applied to types 'Counter' and 'number'. ts(2365)
assert.strictEqual(c + 10, 13) // Operator '+' cannot be applied to types 'Counter' and '10'.ts(2365)

The only workaround I've found is to force-cast the counter to number:

assert.strictEqual(c as unknown as number < 4, true)
assert.strictEqual(c as unknown as number + 10, 13) 
tienpv222 commented 5 years ago

I'm using this ugly:

type A = number & { prop }
let a: A
a + 1

Yet still waiting for any update from the issue.

EKashpersky commented 4 years ago

Quite a surprise we've got this broken. It'd be useful to(for omitting details) have the ability to get default/main value just by using the object with user-defined valueOf/toString methods, and not accessing its fields.

Llorx commented 3 years ago

Another user having problems with this. I'm fiddling with the compiler API, and fixed the problem (somehow) when valueOf has no generics (for example: valueOf():number), but I'm struggling a lot when valueOf is something like this: valueOf<T>(this:T): T extends (...args:any) => any ? ReturnType<T> : T;.

What I've done is, when is going to check the left and right types of an operation, look for the valueOf() method and extract its return value, like so:

const symbol = getPropertyOfType(left, "valueOf");
const type = symbol && getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration);
const signature = type && getSignaturesOfType(type, 0)[0];
left = (signature && signature.getReturnType()) || left;

This will make left operand to be the valueOf() return value, so checks below this code will work as intended.

Lets see if someone can throw a bit of light on how to get the final type of this type: T extends (...args:any) => any ? ReturnType<T> : T.

shamilovtim commented 3 years ago

Bumped into this again today. Surprised there has been no traction on this.

AlbertSu123 commented 3 years ago

Not sure if this is the same problem but when I got that error signature I just changed it from BigInt + BiggerInt to BigInt.plus(BiggerInt)

CMCDragonkai commented 3 years ago

I'm using this ugly:

type A = number & { prop }
let a: A
a + 1

Yet still waiting for any update from the issue.

This failed in the case where I wanted to to do type Id = string & IdInternal where IdInternal extends Uint8Array. When I later tried id[0] = 4, TS complained that id[0] is type never.

GCastilho commented 2 years ago

Why is this still open? I think that after 7 years at least a resolution "yes, we'll implement" or "no, won't do" should be defined, no?

I believe that it should be implemented, especially because it is valid JS and, by the number of issues referencing this one and the amount of comments presenting use cases, it's clearly seen as a bug in Typescript

On my case, I'm using mongoose's Decimal128 type, which converts to number normally, but since typescript won't let me I have to add a bunch of + as in +decimalNumber > normalNumber every time I wants to compare the two

As far as I understand from this thread, the only problem with this new feature is the Date object, which is fair. So, if it is possible to add an exception to the rule, not allowing a date + date or something, I think that it should be done, at least for now. Date is a nightmare anyway

But, if adding an exception is not possible, then I think that at least this issue should be closed with a "won't implement because ", because it's 7 years already, letting this without even a resolution with clearly a lot of people considering this as a bug it's a bit absurd

niedzielski commented 2 years ago

This is a confusing issue for even simple BigInt usage:

function f0(int: BigInt): BigInt {
  return BigInt(int) // Error: Argument of type 'BigInt' is not assignable to parameter of type 'string | number | bigint | boolean'.(2345)
}
console.log(f0(BigInt(1))) // OK: 1

A workaround is:

function f1(int: BigInt): BigInt {
  return BigInt(int.valueOf()) // OK
}
console.log(f1(BigInt(2))) // OK: 2
Llorx commented 2 years ago

This is a confusing issue for even simple BigInt usage:

function f0(int: BigInt): BigInt {
  return BigInt(int) // Error: Argument of type 'BigInt' is not assignable to parameter of type 'string | number | bigint | boolean'.(2345)
}
console.log(f0(BigInt(1))) // OK: 1

A workaround is:

function f1(int: BigInt): BigInt {
  return BigInt(int.valueOf()) // OK
}
console.log(f1(BigInt(2))) // OK: 2

The problem here is that BigInt(X) does not return a BigInt but a bigint. BigInt is not something you can instantiate. For example, you cannot do new BigInt(1) but you can do new Number(1) (which new Number(1) is different from Number(1)). You will also notice how BigInt(1) instanceof BigInt is not true.

The real workaround here is:

function f0(int: bigint) { // The return type is already "bigint"
  return BigInt(int)
}
console.log(f0(BigInt(1)))
mike-lischke commented 1 year ago

I'm using primitive type coercion in my Java Runtime Environment emulation, to provide the same experiences for TS users like there are for Java users. In the Boxing and Unboxing chapter I explain valid use cases, which none-the-less are defeated by the TS compiler.

For a typical use case check the implementation of the java.lang.Long class

dotnetCarpenter commented 1 year ago

@Llorx you are talking about wrapper objects, right?

As @mike-lischke says, they can be used to box/type-cast values as used in java and C# but they are not implemented in the same way in JS.

None of the wrapper objects are meant to be used with the new key word but you can and JS will allow it since you can subclass a wrapper value and in that case you need to call the constructor, which new is syntactic sugar for. Each wrapper object may have different implementations in regard to sub classing. E.g. for BigInt:

is not intended to be used with the new operator or to be subclassed. It may be used as the value of an extends clause of a class definition but a super call to the BigInt constructor will cause an exception. ~https://262.ecma-international.org/#sec-bigint-constructor

Historically wrapper objects were suppose to be instantiated with new but this have since been removed from the specification. IIRC 2011ish but all browsers supported wrapping without the use of new. ~@dotnetcarpenter

This issue is about valueOf which is called implicitly when you use arithmetic operators. E.g. +, -, / or *. This is not to be confused with what happens when you try to add (with +) an object to a string, where the toString method is called. E.g:

const def = {
  toString () { return "def" },
  valueOf () { return 123 }
}

console.log (
// When String is called as a function rather than
//  as a constructor, it performs a type conversion.
  ("abc" + String (def)),   // <- "abcdef"
// In TypeScript you will get an error below saying
// Operator '+' cannot be applied to types 'number' and '{ toString(): string; valueOf(): number; }'.
  (1 + def),            // <- 124
)

The use of valueOf, to return a primitive value that is not a String, has been in JS since, at least, version 3 while TypeScript has never had this.

const dateA = new Date ("1981-04-10T10:00:00")
const dateB = new Date ("1981-04-10T12:00:00")

console.log ((dateB - dateA) / 1000 / 60 / 60 + " hours") // <- "2 hours"
/* TypeScript Errors:
    The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
    The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
*/

PS. Makes you wonder how TypeScript can be a super language of JS, when it has never fully supported JS. PPS. Weirdly enough TypeScript does allow + for dates as long as the hint is string. Meaning that TypeScript is happy as long as the conversion is to string and not number.

console.log (
  "Today's date is " + new Date())
// "Today's date is Thu Dec 29 2022 19:30:56 GMT+0100 (Central European Standard Time)" 

Playground Link

aradalvand commented 1 year ago

No updates on this whatsoever?! It's a highly requested feature and is almost 8 years old. Please at least give us a hint or two as to whether or not it's being planned.

Thanks.

RyanCavanaugh commented 1 year ago

Our iteration plans are public and you can check them to see if things are in there; no hints are needed. I don't think it's particularly likely to happen soon given that doing these operators on wrapped values is somewhat rare, and Date throws a big wrench in the works as mentioned above.

In general if an issue is old, it's probably old for reasons that can be found in the thread. Language design is not a first-in first-out queue and things which don't seem tractable "for now" are generally left open since people complain more if we close them.

CryptoCrocodile commented 1 year ago

Also a +1 for this. The compiler should recognize the presence of valueOf and allow its usage as per the JS standard.

rkrisztian commented 1 year ago

I agree that JavaScript works in weird ways (https://github.com/microsoft/TypeScript/issues/2361#issuecomment-82412122), but Date is definitely not the only problem JavaScript has, and TypeScript is supposed to be a superset of JavaScript (https://github.com/microsoft/TypeScript/issues/2361#issuecomment-1367497616). So to me it comes as a surprise to see that Date.now() < x compiles in JavaScript, but does not compile in TypeScript.

I have to add though that now with the former explanation, implicit type coercion in JavaScript has its traps, and when you have to understand the specification to figure out how code like Date.now() < x works (especially compared to x + x), I think that's just code that's trying to be too smart and therefore it's impractical (see also: principle of least astonishment), so I am now more inclined to just go with the simpler Date.now() < x.getTime(), at least until Temporal becomes part of the EcmaScript standard... But for the same reason, the error we see in TypeScript could still be a warning, or something for ESLint to catch. (E.g., we could use an appropriately configured no-implicit-coercion rule, but the rule by default disallows convenience expressions like !!'' too, so I see why it's not part of the eslint-recommended ruleset. Plus, for understandable reasons it does nothing about Date.now() < x.valueOf(), which I also consider puzzling. Oh wow, to me it looks like I'm starting to open Pandora's box with this, so I'll stop here.)

mpaperno commented 10 months ago

This is very frustrating.

I have a custom "dynamic properties" implementation where objects can hold various values or collections of them (for purposes like change events, de/serializing, view delegates, etc). These dynamic property objects can easily return whatever type they actually hold, including primitives, using valueOf()/toString()/[Symbol.toPrimitive]() overrides. But not in TS.

I don't understand the argument that it's not a "commonly" used feature... those are rather major features of the JS/ECMA. This is why they can be overridden, right? We don't have direct access to [[Get]] and [[Set]] (as far as I know), so those are the next best thing.

declare interface StringProperty {
    valueOf(): string;
    [Symbol.toPrimitive] (h: 'default') : string;
}

How to make this work? Seems basic to me, but clearly I'm missing whatever the underlying issue is. But I'd like it to work... :)

I'm OK with all the extra TS declarations and annotations required to help ensure type safety and IDE hinting. Return on investment. But when it starts requiring actual code workarounds... that's too much IMHO.

Thank you for your consideration. -Max

daxliniere commented 5 months ago

BUMP!!!

pedropedruzzi commented 4 months ago

@RyanCavanaugh could you briefly comment on @icholy's propostal described in this comment and implemented at this branch (not hacky at all) ? It seems like a sensible approach to me as it manages to handle Date. Date type is detected to be StrictOperand<string> not StrictOperand<number>.

My impression is that most wrapped primitive use-cases are number based as opposed to other primitive types. Considering this and also the fact that Date is quirky for addition, there is an even simpler apprach that only handles the number case. In this case, we just make sure to keep Date out: adding Dates would still not be allowed.

Kindly clarify what would be needed for such a proposal to be considered. Thank you.