NOTE: Drafted with AI assistance based on a coding session; edited by me.

If you have ever run a dependency update and seen a version like 1.4.2-beta.1, you have met Semantic Versioning, even if you did not know the name. Java does not have anything built in that follows it. So I decided to build my own small library from scratch, and I learned a lot along the way. This post walks you through how it works and what problems you run into when you try to parse and compare a few numbers separated by dots, which is not as simple as it sounds.

You can find the full source code here: semver-library on GitHub

I will not paste the whole codebase in this post. Instead, I want to show you the ideas behind it, with small snippets you can try yourself.

What Even Is Semantic Versioning

A version has three numbers: MAJOR.MINOR.PATCH, for example 2.4.7. The numbers are a message to the user about whether an upgrade is safe:

  • Fix a bug without changing how the library is used: 2.4.7 becomes 2.4.8.
  • Add a feature without breaking anything: 2.4.7 becomes 2.5.0. The patch resets to 0.
  • Break compatibility: 2.4.7 becomes 3.0.0. Minor and patch reset to 0.

Two optional parts can follow the numbers. A pre-release comes after a hyphen, as in 1.4.2-beta.1, and means "a candidate for 1.4.2, not 1.4.2 itself". Build metadata comes after a plus sign, as in 1.4.2+build5, and is ignored when comparing versions.

Sounds simple, right? Try this: which is newer, 1.9.0 or 1.10.0? As text, "9" comes after "1", so a plain string comparison says 1.9.0. But the parts are numbers, and 10 is bigger than 9, so 1.10.0 is newer. It gets more interesting once pre-releases show up. The spec gives this chain, from lowest to highest:

1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
  < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0

Step One: An Immutable Version That Fails Loudly

The core of the library is a small Version record. A record is immutable by default, so a version can be passed around safely. But a record does not validate anything on its own, so new Version(-1, 0, 0) goes straight through. The place for the check is the compact constructor, the one with no parameter list:

public record Version(int major, int minor, int patch) {
    public Version {
        if (major < 0 || minor < 0 || patch < 0) {
            throw new SemverInvalidInputException("Invalid version arguments");
        }
    }
}

SemverInvalidInputException is my own exception, and it extends IllegalArgumentException. That makes it unchecked, so nobody is forced into a try/catch for bad input, and code that already catches IllegalArgumentException keeps working.

Later I added two more components, preRelease and build, both as strings. That raised a design question: what does "no pre-release" look like? There were three options:

  • null: clearly "absent", but every place that reads it must remember a null check, and a forgotten one becomes a NullPointerException.
  • Optional<String>: it is meant for return values, not for parameters or fields, so it was out.
  • An empty string "": nothing can be null, so preRelease.isEmpty() is always safe. The cost is that "" becomes a magic value.

I chose the empty string, and the constructor rejects null.

One more thing I could not change: a record's constructor must be at least as visible as the record itself. So new Version(...) stays public next to Version.parse(...). I kept it, and I accept that its shape is now part of the public API.

Step Two: Parsing Is Not Just split

The obvious way to parse "1.4.2" is to split on the dots and call Integer.parseInt on each part. Three things went wrong with that.

First, split throws away trailing empty parts. "1.4.".split("\\.") gives 2 parts, not 3, so a broken version looks shorter and gets treated differently. Passing -1 as a second argument keeps them: split("\\.", -1). Now the empty third part reaches the number check and is rejected.

Second, Integer.parseInt is too generous. It accepts "+1" and "-1". Worse, it accepts digits from any script. I ran "١.٢.٣", which are Arabic-Indic digits, and got back Version[major=1, minor=2, patch=3, ...]. The spec says version numbers use only the digits 0-9. Character.isDigit does not help, because it returns true for those digits too. The fix is to check each character against the ASCII range before calling parseInt:

private static boolean isNumber(String s) {
    for (char c : s.toCharArray()) {
        if (c < '0' || c > '9') {
            return false;
        }
    }
    return true;
}

Third, my first leading-zero check rejected any part that started with 0. That also rejected the lone 0, so 0.0.0 failed. A leading zero is only a problem when more digits follow it:

private static boolean validNumber(String v) {
    if (v.startsWith("0") && v.length() > 1) {
        return false;
    }
    return isNumber(v);
}

A number that is too big for an int, like 99999999999.0.0, makes parseInt throw a NumberFormatException. I catch it and throw my own exception instead, so callers only ever see one exception type.

The pre-release label needed its own validation, and I made a classic mistake there. I wanted "a character is bad if it is not a digit, not a letter and not a dash". I wrote:

if (!isDigit(c) || !isEnglishLetter(c) || c != '-') { /* bad */ }

I traced two characters by hand. For 'b', which is valid, the condition is true. For '_', which is not, it is also true. A character can never be a digit, a letter and a dash at the same time, so the condition was true for everything. The fix was to turn each || into &&, which matches the sentence I had in mind.

Step Three: Comparing Pre-releases

Comparing the three numbers is easy. When they are equal, a version without a pre-release is higher than one with it (1.0.0-rc.1 < 1.0.0). When both have a pre-release, you compare its dot-separated identifiers from left to right and stop at the first difference:

  • Both numeric: compare as numbers (beta.2 < beta.11).
  • Both text: compare in ASCII order (alpha < beta).
  • A numeric identifier is lower than a text one (alpha.1 < alpha.beta).
  • If everything shared is equal, the one with more identifiers is higher (beta < beta.2).

"Stop at the first difference" matters more than it sounds. alpha.2 < beta.1 is true, because alpha and beta already differ. The 2 and the 1 are never looked at.

Numeric identifiers can be any length, so converting them with parseInt can overflow. But numeric identifiers have no leading zeros, so a longer digit string is always the bigger number. When the lengths are equal, you compare character by character. No conversion needed:

if (identifier.length() < otherIdentifier.length()) {
    return -1;
} else if (identifier.length() > otherIdentifier.length()) {
    return 1;
} else {
    for (int j = 0; j < identifier.length(); j++) {
        if (identifier.charAt(j) < otherIdentifier.charAt(j)) return -1;
        if (identifier.charAt(j) > otherIdentifier.charAt(j)) return 1;
    }
}

My first version of this code had a bug. After the character loop, and again in the text branch, I had a return 0. That means "these two identifiers are equal, so the whole versions are equal", and it exits compareTo immediately. I found it by running a small driver with six pairs. 1.0.0-beta.2 against 1.0.0-beta.11 printed 0, because the first identifiers, beta and beta, matched and returned before the loop ever reached 2 and 11. The fix was to delete both return 0 lines. When two identifiers are equal, the loop must simply move on to the next pair, and the identifier count at the end decides if everything was equal.

Step Four: Build Metadata and the Hyphen Trap

Build metadata, like 1.0.0+exp.sha.5114f85, follows a plus sign and is never part of the comparison. Its rules are almost the same as the pre-release rules, with one difference: leading zeros are allowed, so 001 is valid in a build but invalid as a numeric pre-release identifier. That is why I could not reuse the pre-release validation for it.

Parsing it looked easy: find the first - for the pre-release and the first + for the build. Then I ran 1.0.0+21AF26D3----117B344092BD, a valid version from the spec, and it was rejected. The hyphens belong to the build text, but my code saw a -, assumed a pre-release had started, and found it empty. A - only starts a pre-release if it comes before the + (or there is no + at all):

if (firstMinus != -1 && (firstPlus == -1 || firstMinus < firstPlus) && preRelease.isEmpty()) {
    throw new SemverInvalidInputException("Invalid version number " + s + hint);
}

Because the build is ignored in comparisons, 1.0.0+a and 1.0.0+b have the same precedence. That raised the question of what equals should say. A record's default equals compares every component, so it would call them different, while compareTo says they are equal. I decided that equals should follow compareTo:

@Override
public boolean equals(Object other) {
    return this == other || (other instanceof Version v) && (compareTo(v) == 0);
}

hashCode has to follow the same rule, so equal versions always have equal hash codes. That means the build must be left out of the hash. If it were included, a HashSet would hold both 1.0.0+a and 1.0.0+b, even though they are equal.

Step Five: The Feature I Cut

My original idea included range matching: expressions like ^1.2.0 and ~1.4 that say "any version compatible with this one". Then I read the spec properly and found out that ranges are not in it. The spec only says what a version looks like and how versions are ordered. Each ecosystem invented its own range syntax, and they differ in the details, for example how pre-releases are matched.

I did not want to ship something tied to one ecosystem's rules, with a list of quirks to document. So I cut it. What is left follows SemVer 2.0.0 exactly, and it is small enough to be finished. Anyone who needs ranges can build them on top of parse and compareTo, which is also a good example of what the library is for.

Step Six: Tests That Caught Real Bugs

Tests did not only confirm my code, they also showed me that two of my own tests were weak.

My first happy-path test compared whole objects:

assertEquals(new Version(1, 4, 2, "", ""), Version.parse("1.4.2"));

That test passes even if parse returns a wrong build, because equals ignores the build. I changed it to check every field on its own:

@Test
void plainVersionIsParsed() {
    Version actual = Version.parse("1.4.2");

    assertEquals(1, actual.major());
    assertEquals(4, actual.minor());
    assertEquals(2, actual.patch());
    assertEquals("", actual.preRelease());
    assertEquals("", actual.build());
}

The second weak test was a list of invalid inputs. I wrote it with IllegalArgumentException.class because my editor suggested it. But NumberFormatException is a subclass of IllegalArgumentException, so if parse ever leaked a raw NumberFormatException, the test would still pass. Converting that exception was the whole reason for the catch I added in Step Two. I changed the test to expect my own exception:

@ParameterizedTest
@ValueSource(strings = { "", " 1.4.2", "1.-4.2", "1.4.2.5", "9999999999999999.0.0" })
void parseThrowsWhen(String value) {
    assertThrows(SemverInvalidInputException.class, () -> Version.parse(value));
}

A parameterized test runs the same body once for every input, so each rule gets one method, and a failure names the exact input.

Full source, tests, and the README with usage examples live here: github.com/KonstantineVashalomidze/semver-library