Skip to main content
  1. Posts/

Everything Is a String Until It Isn't

·1238 words·6 mins
Photograph By Artem Maltsev
Blog Software Engineering Languages
Table of Contents

The Bug That Wasn’t There
#

Last month I spent forty minutes debugging a price calculation that was off by exactly one cent. The inputs looked right. The math looked right. The test was green in one environment and red in another.

The problem? A JSON payload came in with a price as "19.99" — a string — and somewhere downstream it got added to a number with +. In JavaScript, "19.99" + 0.01 doesn’t give you 20.00. It gives you "19.990.01". A string. Which then got passed to the next function, which tried to subtract from it, and that worked (because JavaScript), giving you 19.98001. The type was wrong, the math was wrong, but nothing threw an error. The value just quietly propagated through the system like a rumor.

Type coercion bugs are the cockroaches of software: they survive everything, they’re everywhere, and by the time you see one, there are twenty more you haven’t found.

“5” + 3 = “53” (A Love Letter to JavaScript)
#

JavaScript’s type coercion is the most generous interpretation of “just make it work” in the history of programming. The + operator looks at its operands and thinks: “One of these is a string? Cool, we’re concatenating now.”

"5" + 3; // "53" — string wins
"5" - 3; // 2   — but subtraction forces a number
0 == ""; // true — because of course
0 == "0"; // true
"" == "0"; // false — wait, transitivity broke

The + operator has two jobs: addition and concatenation. If either side is a string, concatenation wins. But - only does math, so it coerces to number. This asymmetry is the source of about 40% of JavaScript memes and 100% of junior dev tears.

Then there’s the falsy hall of fame: 0, "", null, undefined, NaN, and false. Six values that are all falsy but not all equal to each other. NaN !== NaN because IEEE 754 said so, and null == undefined is true but null == 0 is false. There are rules here, technically — they just don’t match any human’s intuition.

parseInt("123abc"); // 123 — sure, close enough
Number(""); // 0   — empty string is zero, naturally

parseInt will cheerfully parse until it hits something it doesn’t like and then stop. No error. No warning. Just… partial results. Number("") returns 0 because an empty string is obviously zero. (It isn’t.)

The one legitimate use of ==: checking value == null catches both null and undefined in a single check. Every other time, use ===. TypeScript helps here — string + number compiles fine, which is one of those cases where TypeScript’s JavaScript heritage shows through the type system’s cracks.

Integer Amnesia (Java’s Polite Lies)
#

Java feels safer. You’ve got strong types, a compiler that yells at you, and no implicit coercion between strings and numbers. But Java has its own version of type coercion — it just wears a nicer suit.

Autoboxing converts between int and Integer silently. Most of the time this is fine. Then you hit the Integer cache:

Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true — cached

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false — different objects

Java caches Integer objects for values -128 to 127. Inside that range, == works because both variables point to the same cached object. Outside it, you’re comparing references to two different objects on the heap. The fix is .equals(), always — but the bug is insidious because it works in tests (which tend to use small numbers) and fails in production (which doesn’t).

The autoboxing null trap is worse:

Integer quantity = null;
int count = quantity; // NullPointerException — surprise!

Unboxing null to a primitive throws an NPE. No compile-time warning, no indication that this line is dangerous. It looks like a simple assignment.

And then there’s BigDecimal, where new BigDecimal("33.0").equals(new BigDecimal("33.00")) returns false — because equals() checks scale. You want compareTo() == 0. This one is in every Java interview question list and still bites people in production, which tells you everything about how intuitive it is.

TypeError Is a Feature (Ruby’s Opinionated Stance)
#

Ruby takes the opposite approach from JavaScript: if you want to mix types, you have to be explicit about it.

"hello" + 123   # TypeError: no implicit conversion of Integer into String
"hello" + 123.to_s  # "hello123" — you asked for it

Where JavaScript guesses your intent and Java silently boxes things, Ruby refuses to guess. Want a string? Call .to_s. Want an integer? Call .to_i. The conversion is always your responsibility, and the code reads like a declaration of intent.

Ruby also draws a distinction between explicit and implicit conversion methods. .to_s and .to_i are explicit — anything can call them. .to_str and .to_int are implicit — only objects that truly are string-like or integer-like should implement them. If you define to_str on your class, you’re telling Ruby “this object can stand in for a String anywhere.” It’s a contract, not a convenience method.

And then there’s truthiness. I wrote about this before — in Ruby, only nil and false are falsy. Zero is truthy. Empty string is truthy. Empty array is truthy. Coming from JavaScript where half the periodic table is falsy, this felt radical. But it means if count actually checks “does this value exist?” rather than “is this value a number I consider meaningful?” — and that turns out to be a more useful question most of the time.

The Type Graveyard (Where JSON Goes to Die)
#

None of this matters if your types die at the boundary. And they do — every time you serialize to JSON.

JSON has six types: string, number, boolean, null, object, array. Your BigDecimal? It’s a number now (or a string, depending on your serializer). Your Date? A string. Your 64-bit integer? Silently truncated, because JSON numbers are IEEE 754 doubles, and Number.MAX_SAFE_INTEGER is 2^53 - 1. That ID column your database auto-increments? It’ll start losing precision around 9 quadrillion, which sounds like a lot until you realize Twitter hit it years ago (and switched to string IDs).

JSON.parse('{"id": 9007199254740993}');
// { id: 9007199254740992 } — off by one, silently

No error. No warning. The number just… changes. This is why every API that deals with large integers serializes them as strings. It’s also why you should never trust that a JSON payload has the types you expect — validate at the boundary, always.

Living With the Chaos
#

Three languages, three philosophies: JavaScript coerces everything and hopes for the best, Java coerces some things and hides the rest behind autoboxing, Ruby refuses to coerce and makes you do it yourself. They’re all trying to solve the same problem — what happens when types don’t match — and they all create their own category of bugs in the process.

The practical advice is boring but real: use === in JavaScript, use .equals() in Java, validate at every JSON boundary, and write tests with values outside the happy path (128, not 1). The type system catches what it can, but coercion bugs live in the gaps between what the compiler checks and what the runtime does.

Or just use Ruby, where the language tells you to your face that your types don’t match. There’s something refreshing about a TypeError at 2pm instead of a wrong number at 2am.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.

Related

The Dependency You Forgot About
·1238 words·6 mins
Photograph By Jon Tyson
Blog Software Engineering Security
Your lockfile has 847 packages in it. You chose 12. The other 835 are a trust exercise.
Your Tests Are Lying to You
·1254 words·6 mins
Photograph By Nguyen Dang Hoang Nhu
Blog Software Engineering Testing
When all tests pass but production breaks, the problem isn’t your code — it’s where you drew the mock boundary.
The Type System Is a Programming Language
·3032 words·15 mins
Photograph By Towfiqu barbhuiya
Blog TypeScript Software Engineering
Building a fully type-safe objectPick utility that parses dot-paths, handles wildcards, and infers nested return types — all at compile time.