Go · Golang

Go byte units and localized formatting


This article will go through creating byte unit methods for both metric and binary prefixes. The two stringers will take a float64 value and round it with a metric unit.

Decimal(15000).String() would return 15.0 kB
Binary(15000).String() would return 14.6 KiB

Unlike many other byte converters, this will also format the number using international digit separators. While numbers are universal, formatting is not.

For example, 15000.55 can be displayed differently depending on the country or language.

English: 15,000.55
Germany: 15.000,55
France: 15 000,55

Even if your program is primarily in English, it could be nice to allow users to localize numbers and byte units.

You can find the final result on GitHub or run it at Go Playground.

Localized separators

First, create a new global float64 named Decimal and a String method to convert any float64 values to a string.

package main

type Decimal float64

func (n Decimal) String() string {
	return ""
}

Set up the main function with a placeholder number to print.

func main() {
	s := Decimal(1000).String()
	fmt.Println(s)
}

Running this is pointless as it will return a blank newline.

To make this string method useful, let’s apply language support using the Go text extended libraries. First, import the language package and create a t language.Tag parameter for String().

import (
	"fmt"

	"golang.org/x/text/language"
)
func (n Decimal) String(t language.Tag) string {
	return ""
}

Add a new p message printer that formats our float64 value to the supplied language.

import (
	"fmt"

	"golang.org/x/text/language"
	"golang.org/x/text/message"
)
func (n Decimal) String(t language.Tag) string {
	p := message.NewPrinter(t)
	// round the n float to 2 precision points
	return p.Sprintf("%.2f", n)
}

Finally, update the main function to use the t language Tag parameters, using English, German and French.

func main() {
	const n = 1000.01
	en := Decimal(n).String(language.English)
	de := Decimal(n).String(language.German)
	fr := Decimal(n).String(language.French)
	fmt.Println(en)
	fmt.Println(de)
	fmt.Println(fr)
}

The result so far can be run in Go.

package main

import (
	"fmt"

	"golang.org/x/text/language"
	"golang.org/x/text/message"
)

type Decimal float64

func (n Decimal) String(t language.Tag) string {
	p := message.NewPrinter(t)
	// round the n float to 2 precision points
	return p.Sprintf("%.2f", n)
}

func main() {
	const n = 1000.01
	en := Decimal(n).String(language.English)
	de := Decimal(n).String(language.German)
	fr := Decimal(n).String(language.French)
	fmt.Println(en)
	fmt.Println(de)
	fmt.Println(fr)
}
$ go run .
1,000.01
1.000,01
1 000,01

Decimal units

Below the Decimal type, create a new kb const with a Decimal float64 type and a value of 1000.

type Decimal float64

const kb Decimal = 1000

Back to the String method, we will implement the kB unit. Create an x variable for switch case usage and a copy of the n Decimal float to the f variable. Finally, update the return to use the f variable.

func (n Decimal) String(t language.Tag) string {    
        p := message.NewPrinter(t)
        f := n
        x := n
        switch {}
        // round the n float to 2 precision points
	return p.Sprintf("%.2f", f)
}

Update the switch to handle any x value greater or equal to the kb const and move the p message printer to the default case.

switch {
	case x >= kb:
		// do nothing
	default:
		// round the n float to 2 precision points
		return p.Sprintf("%.2f", f)
}

In the switch case, divide the f Decimal value by the kb const and return the result. The Sprintf method also should return the kilobyte unit symbol.

f /= kb is the same as f = f / kb.

switch {
	case x >= kb:
        f /= kb
        // round the n float to 1 precision point
        return p.Sprintf("%.1f %s", f, "kB")
	default:
		// round the n float to 2 precision points
		return p.Sprintf("%.2f", f)
}

We need to remove the two precision points from the default case as it returns the value as bytes, which display as integers, not precision floats.

	default:
		return p.Sprintf("%.0f", f)

The result so far should convert any byte value 1000 or greater into Kilobytes with one precision point. All other values get kept as bytes.

package main

import (
	"fmt"

	"golang.org/x/text/language"
	"golang.org/x/text/message"
)

type Decimal float64

const kb Decimal = 1000

func (n Decimal) String(t language.Tag) string {
	p := message.NewPrinter(t)
	f := n
	x := n
	switch {
	case x >= kb:
		f /= kb
		// round the n float to 1 precision point
		return p.Sprintf("%.1f %s", f, "kB")
	default:
		return p.Sprintf("%.0f", f)
	}
}

func main() {
	const n = 1000.01
	en := Decimal(n).String(language.English)
	de := Decimal(n).String(language.German)
	fr := Decimal(n).String(language.French)
	fmt.Println(en)
	fmt.Println(de)
	fmt.Println(fr)
	fmt.Println(Decimal(123).String(language.Arabic))
}
$ go run .
1.0 kB
1,0 kB
1,0 kB
١٢٣

You can play around changing the n const value. But the switch logic has a problem. Whenever the n value is negative, the String method will display it as bytes.

	const n = -1000.01
$ go run .
-1,000

To negate this, update the x var to save the absolute value of n.

import (
	"fmt"
	"math"
func (n Decimal) String(t language.Tag) string {
    p := message.NewPrinter(t)
    f := n
	x := Decimal(math.Abs(float64(n)))
$ go run .
-1.0 kB

Let’s add support for Megabytes. Create a new mb Decimal const with a value of one million.

const (
	kb Decimal = 1_000
	mb Decimal = 1_000_000
)

Add a new case in the switch above the existing kb case to handle n values greater or equal to mb. All units that are equal or larger than a Megabyte will use all caps symbols and two precision points.

	switch {
    case x >= mb:
        f /= mb
		// round the n float to 2 precision points
        return p.Sprintf("%.2f %s", f, "MB")
	case x >= kb:
		f /= kb
		// round the n float to 1 precision point
		return p.Sprintf("%.1f %s", f, "kB")
	default:
		return p.Sprintf("%.0f", f)
	}

To test the new case, change the n value in main to a byte value larger than a million.

func main() {
	const n = 9_876_543
$ go run .
9.88 MB

Now we will finish up the method to support units up to Exabytes (EB). In the Decimal const collection, add variables gb, tb, pb, and eb. Instead of using super long integer values, use the more manageable scientific notation.

const (
	kb Decimal = 1e+3
	mb Decimal = 1e+6
	gb Decimal = 1e+9
	tb Decimal = 1e+12
	pb Decimal = 1e+15
	eb Decimal = 1e+18
)

If these floats seem cryptic, consider the values as the digit 1 with x number of zeros.

kB = 1e+3 is a 1 combined with three zeros to make 1000.
MB = 1e+6 is 1 combined with six zeros to make 1000000.

Create three constants to hold the various floating-point formats with none, one, and two precision points.

const (
    precision0 = "%.0f"
    precision1 = "%.1f\u00A0%s"
    precision2 = "%.2f\u00A0%s"
)

%f prints the float value.
%s prints the unit symbols (kB, MB, etc.).
\u00A0 is Unicode codepoint 160, no-break space to ensure the unit symbol is always with the float value.
The const precision0 is for byte values, so there is no need for a unit symbol.

In the switch, replace the p.Sprintf formats with the precision consts.

	switch {
    case x >= mb:
        f /= mb
        return p.Sprintf(precision2, f, "MB")
	case x >= kb:
		f /= kb
		return p.Sprintf(precision1, f, "kB")
	default:
		return p.Sprintf(precision0, f)
	}

Complete the switch with cases to support EB, PB, TB, and GB units in descending order.

	switch {
	case x >= eb:
		f /= eb
		return p.Sprintf(precision2, f, "EB")
	case x >= pb:
		f /= pb
		return p.Sprintf(precision2, f, "PB")
	case x >= tb:
		f /= tb
		return p.Sprintf(precision2, f, "TB")
	case x >= gb:
		f /= gb
		return p.Sprintf(precision2, f, "GB")
	case x >= mb:
		f /= mb
		return p.Sprintf(precision2, f, "MB")
	case x >= kb:
		f /= kb
		return p.Sprintf(precision1, f, "kB")
	default:
		return p.Sprintf(precision0, f)
	}

Try out some silly large n byte values and run the results.

func main() {
	const n = 92233720368547758079223
	en := language.English
	de := language.German
	fr := language.French
	fmt.Println("English", Decimal(n).String(en))
	fmt.Println("German", Decimal(n).String(de))
	fmt.Println("French", Decimal(n).String(fr))
}
$ go run .
English 92,233.72 EB
German 92.233,72 EB
French 92 233,72 EB

Binary units

Creating the Binary method will be much quicker as it’s very similar to the Decimal method. The main difference is while the Decimal uses Base-10 metrics (KB = 1000), the Binary uses Base-2 metrics (KiB = 1024).

Create a new Binary type and constants for the binary bytes. These will rely on the left shift operator.

type (
	Decimal float64
	Binary  float64
)

const (
	kib Binary = 1 << 10
	mib Binary = 1 << 20
	gib Binary = 1 << 30
	tib Binary = 1 << 40
	pib Binary = 1 << 50
	eib Binary = 1 << 60
)

If these are confusing, left-shifting implements powers of 2.

1 << 1 equates to 2
1 << 2 equates to 2 * 2 = 4
1 << 3 equates to 2 * 2 * 2 = 8
1 << 10 equates to 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 = 1,024
1 << 20 equates to 1024 * 1024 = 1,048,576
1 << 30 equates to 1024 * 1024 * 1024 = 1,073,741,824

Create a duplicate of the Decimal String method but replace the Decimal with Binary types.

func (n Binary) String(t language.Tag) string {
    p := message.NewPrinter(t)
    f := n
    x := Binary(math.Abs(float64(n)))
	switch {
	case x >= eb:
		f /= eb
		return p.Sprintf(precision2, f, "EB")
	case x >= pb:
		f /= pb
		return p.Sprintf(precision2, f, "PB")
	case x >= tb:
		f /= tb
		return p.Sprintf(precision2, f, "TB")
	case x >= gb:
		f /= gb
		return p.Sprintf(precision2, f, "GB")
	case x >= mb:
		f /= mb
		return p.Sprintf(precision2, f, "MB")
	case x >= kb:
		f /= kb
		return p.Sprintf(precision1, f, "kB")
	default:
		return p.Sprintf(precision0, f)
	}
}

Finally, update the switch cases to use the binary constants and return binary units.

func (n Binary) String(t language.Tag) string {
	p := message.NewPrinter(t)
	f := n
	x := Binary(math.Abs(float64(n)))
	switch {
	case x >= eib:
		f /= eib
		return p.Sprintf(precision2, f, "EiB")
	case x >= pib:
		f /= pib
		return p.Sprintf(precision2, f, "PiB")
	case x >= tib:
		f /= tib
		return p.Sprintf(precision2, f, "TiB")
	case x >= gib:
		f /= gib
		return p.Sprintf(precision2, f, "GiB")
	case x >= mib:
		f /= mib
		return p.Sprintf(precision2, f, "MiB")
	case x >= kib:
		f /= kib
		return p.Sprintf(precision1, f, "KiB")
	default:
		return p.Sprintf(precision0, f)
	}
}

Let’s wrap it up and test it out by adding some Binary methods in the main func.

package main

import (
	"fmt"
	"math"

	"golang.org/x/text/language"
	"golang.org/x/text/message"
)

type (
	Decimal float64
	Binary  float64
)

const (
	kb Decimal = 1e+03
	mb Decimal = 1e+06
	gb Decimal = 1e+09
	tb Decimal = 1e+12
	pb Decimal = 1e+15
	eb Decimal = 1e+18
)

const (
	kib Binary = 1 << 10
	mib Binary = 1 << 20
	gib Binary = 1 << 30
	tib Binary = 1 << 40
	pib Binary = 1 << 50
	eib Binary = 1 << 60
)

const (
	precision0 = "%.0f"
	precision1 = "%.1f\u00A0%s"
	precision2 = "%.2f\u00A0%s"
)

func main() {
	const n = 10000000000
	en := language.English
	de := language.German
	fr := language.French
	fmt.Println("English", Decimal(n).String(en))
	fmt.Println("English", Binary(n).String(en))
	fmt.Println("German", Decimal(n).String(de))
	fmt.Println("German", Binary(n).String(de))
	fmt.Println("French", Decimal(n).String(fr))
	fmt.Println("French", Binary(n).String(fr))
}

func (n Decimal) String(t language.Tag) string {
	p := message.NewPrinter(t)
	f := n
	x := Decimal(math.Abs(float64(n)))
	switch {
	case x >= eb:
		f /= eb
		return p.Sprintf(precision2, f, "EB")
	case x >= pb:
		f /= pb
		return p.Sprintf(precision2, f, "PB")
	case x >= tb:
		f /= tb
		return p.Sprintf(precision2, f, "TB")
	case x >= gb:
		f /= gb
		return p.Sprintf(precision2, f, "GB")
	case x >= mb:
		f /= mb
		return p.Sprintf(precision2, f, "MB")
	case x >= kb:
		f /= kb
		return p.Sprintf(precision1, f, "kB")
	default:
		return p.Sprintf(precision0, f)
	}
}

func (n Binary) String(t language.Tag) string {
	p := message.NewPrinter(t)
	f := n
	x := Binary(math.Abs(float64(n)))
	switch {
	case x >= eib:
		f /= eib
		return p.Sprintf(precision2, f, "EiB")
	case x >= pib:
		f /= pib
		return p.Sprintf(precision2, f, "PiB")
	case x >= tib:
		f /= tib
		return p.Sprintf(precision2, f, "TiB")
	case x >= gib:
		f /= gib
		return p.Sprintf(precision2, f, "GiB")
	case x >= mib:
		f /= mib
		return p.Sprintf(precision2, f, "MiB")
	case x >= kib:
		f /= kib
		return p.Sprintf(precision1, f, "KiB")
	default:
		return p.Sprintf(precision0, f)
	}
}
$ go run .
English 10.00 GB
English 9.31 GiB
German 10,00 GB
German 9,31 GiB
French 10,00 GB
French 9,31 GiB

Congratulations, you now have working Binary and Decimal methods. I’ve also written a collection of unit tests for both. You can find the code on GitHub or run it at Go Playground.

Example of the program displaying the output of 10 GB in multiple locals.

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s