Everything About Numbers In A Sheet Worker

In a recent forum post, I described how to make sure your numbers have a fixed number of decimal places. Often when you use money, you have 2 decimals, like $3.30, and when using weights you might have three deminals like 123.650Kg. Handling such situations can be very simple, but is also sometimes very complicated. I thought that would make a great topic for a post, then realised there was a lot more to say about how JavaScript handles numbers.

I’ve briefly covered numbers before (see a brief introduction to data types, how to do arithmetic, and an introduction to strings), and I’ll revisit some of that here while covering the topic more comprehensively. I’ll go into way too much detail explaining why this is sometimes complicated – this is a massive post!

The Basics

Strings, Numbers, and other Data types

A string is basically any bunch of letters and numbers, like “word” or “this is a string”, or even “area 51”.

A number either has decimal places or it doesn’t. We call the latter an integer, but in javascript, both types are numbers and are treated the same. You can add an integer like 7 and a floating point number like 7.12, and JS won’t complain – you’ll get a new floating point number 14.12. If you really want that final number to be an integer (14), you’ll have to do something to that number- JS won’t do it automatically.

There are other data types, like arrays and objects, but we are mainly concerned with numbers and strings here.

Creating Attributes

When you create attributes in HTML, it always starts out as a string.

<input type="number" name="attr_example_number" value="10">
<input type="text" name="attr_example_string" value="18">
Code language: HTML, XML (xml)

In this case, example_number has a value of “10” and example_string is “18” (note the quotes). While one is defined as a number, both are strings by default. You can confirm this in a sheet worker:

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = values.example_number; // equals "10"
      let s = values.example_string; // equals "18"
      console.info({n, s});
  });Code language: JavaScript (javascript)

Here we use console.info to output the named variables to the console, and interrogate their values. These start with the default values for those attributes, but they are both strings despite one being given a type of number. All attributes on Roll20 are strings (except when they aren’t – more on that below).

Logical Comparisons

Since they are strings, there are some things we can’t do, no matter how much we want to.

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = values.example_number; // equals "10"
      let s = values.example_string; // equals "18"
      let bool = false;
      console.info({n, s, bool}); 
      if(n < s) {
         bool = true;
      }
      console.info({n, s, bool}); 
  });Code language: JavaScript (javascript)

Here we want to test if the variable n is below s. There are many types of logical tests you might want to do. But they are both strings, so this doesnt work. That bool value will always be false. 10 is below 18, but “10” is not below “18” (it’s not greater either). These are strings, and logical comparisons don’t work (here, in JavaScript).

For this to work, we need a way to convert those strings into numbers. We’ll come back to that in a surprising way, but first…

What happens when we try to add strings together?

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = values.example_number; // equals "10"
      let s = values.example_string; // equals "18"
      console.info({n, s});
      let total = n + s;
      console.info({total}); // total = "1018", why is that?
  });Code language: JavaScript (javascript)

You might expect the result to be “28”, but these two variables are strings, so JS “adds” the values as if they were strings (which they are) – thus, it just plonks one on the end of the other, producing “1018”.

So + a tricky operator to use. What about other arithmetic operators, like -, /, and *?

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = values.example_number; // equals "10"
      let s = values.example_string; // equals "18"
      console.info({n, s});
      let total = s - n;
      console.info({total}); // total = 8
  });Code language: JavaScript (javascript)

Javascript always tries to treat strings as numbers, if it can, so the * / and – operators work properly. Notice the total here is not a string (no enclosing quotes: 8 and not “8”).

Arithmetic in Javascript

You can take advantage of this when initially creating the variables.

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = 1 * values.example_number; // equals 10 (this is a number)
      let s = 1 * values.example_string; // equals 18 (not a string)
      console.info({n, s});
      let total = n + s;
      console.info({total}); // total = 28, why is that?
  });Code language: JavaScript (javascript)

Here, by multiplying the initial objects by 1, JS has converted them to a number. This only works because the value is a number, but it is saved as a string. If the value was something like “word”, which is not a number (1 * word makes no sense), you’ll see an error.

Later in the worker, JS sees you are trying to add two numbers together using +, and since they are actually numbers (not strings containing a number), it adds them properly.

The rule is: when you are adding things together, if they are all numbers, JS adds them properly. If even one of the items is a string, every element is treated as a string, which may produce surprising results.

Here’s a mixed example.

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let n = 1 * values.example_number; // equals 10 (this is a number)
      let s = values.example_string; // equals "18" (this is a string)
      console.info({n, s});
      let total = n + s;
      console.info({total}); // total = "1018"
  });Code language: JavaScript (javascript)

Notice in this example, we converted a string to a number – “10” become 10. This was done by multiplying by 1. If you perform any arithmetic operation, the result is converted to a number. we could have done any of these:

on('change:example_number', () => {
  getAttrs(['example_number'], values => {
      let m = 1 * values.example_number; // equals 10 (this is a number)
      let d = (values.example_number) / 1; // this is also a number: 10
      let s = values.example_number - 0; // here is another a number: 10
      let a = values.example_number + 0; // oh oh, this is not a number: "100"
      console.info({m, d, s, a});
  });Code language: JavaScript (javascript)

In that last one, the + operator does double duty in JavaScript. It can be used as addition if every element is a number, but also is used as the concatenation operator (add one string to the end of another). Here, one of the elements is a string, so concatenation is performed.

As an aside, if you’re creating a HTML macro, this is one of the biggest source of errors. Make sure each number is enclosed in parentheses. But that’s another topic.

The Big Topic: Coercing Strings Into Numbers

So, numbers might be stored in strings. In Roll20, that’s common. When we really want to treat those attributes as numbers, it’s a good idea to make sure they are actually numbers and not strings. It’s not always necessary – but it is safer (especially if addition or logical tests are involved).

When you convert data from one type to another, it’s called coercing – we want to coerce strings into numbers. Javascript provides many, many ways to do this. We have seen that we can use arithmetic operators, but there are a few functions created specifically for this purpose. Lets look at them,

Parsing vs Conversion

Here are the four most common functions for this: parseInt, parseFloat, Number, and + (yes, the dreaded + again).

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let pi = parseInt(values.example_number); // equals 10
      let pf = parseFloat(values.example_string); // equals 18
      let n = Number(values.example_number); // equals 10
      let p = +values.example_string; // equals 18
      console.info({pi, pf, n, p});
  });Code language: JavaScript (javascript)

The first two operate in a similar way, the last two act a little differently.

parseInt and parseFloat look at the string, and if it starts with a number, it truncates everything after the number ends. parseInt looks for integers and parseFloat looks for floating point numbers.

So, if your string is, say “14.7 kg”, parseInt will give a result of 14, and parseFloat will give 14.7. Everything after the number required is snipped off. Notice the number isn’t rounded. If you want the number to be rounded, you’ll have to grab it as a floating point, then round it and save the new value.

on('change:example_weight', () => {
  getAttrs(['example_weight'], values => {
      let pf = parseFloat(values.example_weight); // equals 14.7
      let rounded = Math.round(pf); // equals 15
      console.info({pf, rounded});
  });Code language: JavaScript (javascript)

parseInt always gives you the number without any decimal places, and parseFloat keeps any decimal places – if there are any. It doesn’t add extra decimal places, so if the number is already an integer that’s what you get.

Number and +

Number() works just like parseFloat(), except it looks at the whole value, and if there is anything it that can’t be treated as a pure number, it’ll return an error.

So if you try Number(“14.7 kg”) you’ll get an error. With that space and “kg”, it is not a number, and so this fails. A human can see it’s a number, but to a computer it’s just a string of numbers and letters – it is a string. But if you then try Number(“14.7”) you’ll get the expected 14.7.

The + operator is just a shorthand for Number() – it is exactly the same operator, but is very quick to type.

Errors

So there are two types of function here, and it looks like the first two are more robust. But they can create errors too. If the attribute starts with a number, they’ll extract that number. If there are multiple numbers, only the first is grabbed, and if the string does not start with a number, you’ll get an error.

let mult = parseInt("7/11"); // equals 7
let ending = parseInt("Area 51"); // equals NaN - an error
console.info({mult, ending});Code language: JavaScript (javascript)

That first one fails because of the slash – it’s read as the letter “/” not a divisor, because it’s part of a string. So there are two numbers 7 and 11, and the function grabs only the first.

The second fails because the string does not start with a number.

Number would fail on both of these two.

Default Values

If you have an error here, the sheet worker probably fails – especially when you try to do something with those variables later. A common solution is to create default values. That looks like this:

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let pi = parseInt(values.example_number) || 0; // equals 10
      let p = +values.example_string || 0; // equals 18
      console.info({pi, p});
  });Code language: JavaScript (javascript)

The || 0 ending says “OR 0”. If the number calculation is an error, the second value, 0, will be used. You might set a different default value, as appropriate. If the variable is going to be a divisor later, setting it to zero is a bad idea- you don’t want to divide by zero. For example:

on('change:example_number change:example_string', () => {
  getAttrs(['example_number', 'example_string'], values => {
      let pi = parseInt(values.example_number) || 1; // equals 10
      let p = +values.example_string || 100000; // equals 18
      console.info({pi, p});
  });Code language: JavaScript (javascript)

A Thing About Default Values

Here’s a thing people often do:

let pi = parseInt(values.example1) || 0 + parseInt(values.example2) || 0 ;Code language: JavaScript (javascript)

There’s a very serious but also very subtle problem here. It’s not related to the method used to coerce values. It happens with parseFloat, Number, +, or any other method used to coerce values.

Let’s say the first parseInt is an error, what happens?

JS sees the first value contains an error, then jumps to the next value, which is:

let pi = 0 + parseInt(values.example2) || 0 ;Code language: JavaScript (javascript)

That is probably (almost certainly) not what you want. It also means if the first parseInt is NOT an error, you get this:

let pi = parseInt(values.example1);Code language: JavaScript (javascript)

The bit after the || symbol only happens if the first one generates an error, so that second parseInt is completely ignored.

Each parseInt should be isolated, like this:

let pi = (parseInt(values.example1) || 0) + (parseInt(values.example2) || 0);Code language: JavaScript (javascript)

In this way, each branch is properly isolated and looked at separately. All those extra brackets can make calculations look complicated, which is why I’m fond of functions like these:

const int = (score, fallback = 0) => parseInt(score) || fallback;
const float = (score, fallback = 0) => parseFloat(score) || fallback;Code language: JavaScript (javascript)

These one line functions can be put at the start of your script block, and then when you are tempted to write parseInt in a worker, you can instead type:

let example1 = int(values.example1);
let example_total = int(values.example2) + int(values.example3);Code language: JavaScript (javascript)

It properly isolates each value, and the typing is shorter. Win-win. You can see more examples of this kind of function over here.

Other Methods

Any method of manipulating numbers can be used to coerce strings into numbers, just as long as the string is actually a number. The methods above (parseInt, parseFloat, Number, +) are designed for this and are good methods to use. But here are some other methods that also work:

  • Arithmetic: like x1, -0, /1. But avoid +.
  • Rounding: You can use Math.round, Math.ceil, Math.floor, and Math.trunc (truncate) to round strings to numbers. Math.round(“7.7”) will properly give a result of 8, and that result is a number. Math.floor and Math.trunc both round down, but they work diffferent on negative numbers. Math.floor(“-1.1”) gives -2, and Math.trunc(“-1.1”) gives -1. trunc simply truncates to the first decimal point, while floor rounds down – and that works differently if the number is negative.
  • Math functions: there are other Math.something functions, and they’ll all turn a string into a number. Basically any attempt to treat a string as a number produces a number.

Remember to include default values, because if the string is not a number these methods generate an error.

Which To Use?

There’s no real reason to use Number() when + is available. The choice between + and the parse functions is entirely one of personal preferences. Remember that JS doesn’t consider Integers to be special. If the original data is an integer, the value you get will always be an integer, whichever you use.

So there’s an argument to always use +. Personally, I use parseInt whenever the number should be an integer and the player can change the value, and + otherwise. You can’t rely on an input or textarea to contain the data you expect, if players can alter that data. (Some players deliberately try to break things, while others simply make typos. It’s better to be safe.)

Use whichever serves you best. (But always create a default value.)

Data Is Always Stored As A String (except when it’s not)

We are often told that Roll20 saves all attributes as strings, and this is true. Except when it’s not.

A Brief Revision of Data Types

JavaScript supports several data types, and the most common we’ll deal with are strings, numbers, arrays, and objects. You can create them in code, like this:

let a_string = "a string";
let a_number = 13;
let an_array = [12, "a_string", string];
let an_object = {
   a_number: 13,
   a_string: a_string
   a_defined_string: "this is a string",
};
// you can create empty variables using the delimters, but number is different
let empty_string = "";
let empty_number = 0;
let empty_array = [];
let empty_object = {};
let undefined_variable;Code language: JavaScript (javascript)

That last variable created there doesn’t have a date type, but will gain one when you give it data. in JS, data type changes when needed. Any variable can have its data type changed.

JS tries to be smart, and converts data from one type to another as needed. It tries to be smart – it doesn’t always succeed. So it’s a good idea to make sure variables are of the right type when you do something with them.

Variables can be arrays and objects, and even functions (which can be mind minding). But for this essay we are mostly concerned with strings and numbers.

A Few Things About Data Types

let a_string = "a string";
a_string = 13; // no longer a string - now has a value of 13 and is a number.Code language: JavaScript (javascript)

If you define variables with const instead of let, they can’t be changed. They are constant.

const a_string = "a string";
a_string = 13; // returns an error, because consts cant be changed.Code language: JavaScript (javascript)

One thing that might trip you up: when creating empty objects or arrays, the data type can’t be changed, but the contents can.

const empty_object = {};
empty_object.string = "testing" // this doesn't return an error
// now the object is {string: "testing"}Code language: JavaScript (javascript)

Attributes as Numbers

When you grab a variable from an attribute it is, by default, a string. But when you modify an attribute in code, it is often coerced into a number. Then when you save it, it might be saved as a number. Here’s an extremely complex sheet worker to demonstrate this. I’ll explain it afterwards.

on('change:example_string', () => {
  getAttrs(['example_number'], values => {
    let example = parseInt(values.example_number) || 0; // a number equalling 10
  setAttrs({
    example_number: example
  }, () => {
      getAttrs(['example_number'], values => {
        let example2 = values.example_number; // a <strong>number</strong> equalling 10
        console.info({example2});
      });
    });
  });
});Code language: JavaScript (javascript)

First we have a normal sheet worker that watches the example_string attribute for changes. But the sheet worker doesn’t touch that, to make it easy to avoid infinite loops. (Try to imagine what would happen if we watched example_number for changes, then in the worker, changed it).

Then the worker grabs the value of example_number and coerces it into a number, and with setAttrs saves that modified attribute.

Now the second part of the setAttrs function that starts () => is another function that runs after the first one has completed. It only runs after the setAttrs has completed. You can use this technique to run functions after a change.

Our last worker gets the attribute again after its change, and now we see it is NOT a string – it is a number.

What This Tells Us

This is a fairly convoluted way of showing that when you save a number, it might remain a number data type. It is NOT a string.

This isn’t really useful, because you can’t be sure if an attribute contains a string or a number, so you have to treat it as if it contains a string. But this means that if you don’t realise the attribute can be of either type and just treat it as a number you can encounter subtle errors.

On the other hand, if you treat is always being a string, you won’t have any errors. Using parseInt or Number() on a value that is already a number won’t cause any problems.

So always treat variables from character sheets as if they were strings. They might not be, but it doesn’t matter – the code will work properly.

Getting a Fixed Number of Decimals

Now on to the original reason for writing this post, which is almost comically simple.

Types of Number

In JavaScript, the number data type is always a floating point number. You can force it to be an integer, but in javascript, an integer is just a Number with no decimal places.

While this may seem obvious, other programming languages often treat integers and floating point numbers as fundamentally different, and mean adding one to the other fails unless you coerce them into the same data type. You don’t need to worry about that.

So parseInt and parseFloat both create a number data type, but parseInt removes any decimal places. They are still the same data type.

This means you don’t technically have to use parseInt. If use Number or + on a number that is already an integer it stays an integer. New decimal places aren’t created.

Creating A Fixed Number of Decimal Places

The problem is, you sometimes want to show a number of decimal places. For example, money is often shown as $3.30 (dollars as integers, and cents as decimals), or weight in thousandths (like 120.650 Kg).

JavaScript will also truncate those to the minimum number of places needed, like 3.3 when you wanted 3.30, or 120.65 when you wanted 120.650.

So can force a number of decimal places to be shown with the toFixed function, like this:

let money = 3.3;
let weight = 120.65
let dollars = money.toFixed(2);
let kg = weight.toFixed(3);Code language: JavaScript (javascript)

Rounding

So, put the number of decimals you want in brackets, and leave empty for 0. By default, the toFixed function rounds to the nearest.

let money = 3.3;
let weight = 120.65
let dollars = money.toFixed(); // a number, 3
let kg = weight.toFixed(); // a number, 121Code language: JavaScript (javascript)

This will show the proper number of integers even if an integer. So 3 can become 3.00.

Bare Numbers

You might find it necessary to apply toFixed to a bare number, like this:

let money = 3.toFixed(2);Code language: JavaScript (javascript)

I can’t remember a time I’ve had to do this, but if you are ever tempted to do this, know that it will fail. But if you surround the number with parenthesis it works. Like this:

let money = (3).toFixed(2);Code language: JavaScript (javascript)

There are many situations where JS functions fail, but if you put parenthesis around the entity, it suddenly works. This is a handy way to trick JS into thinking there is something there that it can work on, so it does. I’ll probably come back to this technique in a later article – it is sometimes useful.

A Decimal Places Function

The previous section described how to convert a number to one with a specific number of decimal places. You can create a function to make this easier.

// a function to convert a number to specifical decimals
const places = (score, decimals = 0) => +score.toFixed(decimals);Code language: JavaScript (javascript)

Place this function at the start of your script block, then when you want to show convert a number to a specific number of decimals, you can do this:

// create a value purely for this example
let value = 43.765432;
// use the places function to convert it into a number with two decimal places.
let money = places(value, 2);
// result = 43.77Code language: JavaScript (javascript)

The places function takes 1 or 2 parameters. You can pass it a value to be acted on, and, optionally, a number of decimal places. For example:

// create a number with no decimal places (an integer)
let integer = places(value);
// create a number with three decimal places
let weight = places(value, 3);
// create a number with a variable number of decimal places
let decimals = 1;
let final_total = places(value, decimals);Code language: JavaScript (javascript)

If you’re having to create a lot of numbers with specific numbers of decimal places, a function like this could be very handy.

Conclusion

So there we have it. How to convert strings to numbers, why we do it the way we do, and how to create numbers with specific numbers of decimal places. Is there anything you want to know? Are there any questions about this article?

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.