Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Scott Markovits
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Scott Markovits@ScottMarkovits )

Using The String localeCompare() Method To Implement A Natural Sort In Angular 8.2.0-next.0

By Ben Nadel on

The other day, while exploring the Fat-Arrow function support in Lucee 5.3, I used the compare() function to implement a .sort() operation. I had never used ColdFusion's compare() function in this capacity before; and, it seemed like such a natural fit (based on its return-values) that I wanted to see if JavaScript had something similar. This is when I came across the String.prototype.localeCompare() method. I had never seen this method before, so I sat down to play with it. And, I was immediately excited to see that it has an option for using numeric collation! Which makes it kind of perfect for implementing a "natural sort". To demonstrate this, I put together a small Angular 8.2.0-next.0 demo.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

A "natural sort" is one in which numbers are treated as numbers, even when they are embedded within a string. So, for example, a natural sort would yield the following set of ordered items:

  • Item 1
  • Item 5
  • Item 100

Notice that the 100 comes after the 5. With a typical ASCII sort, the 100 would come before the 5 since the ASCII character 1 sorts before the ASCII character 5. However, with a "natural sort", the substrings 5 and 100 are compared as numbers, not as a string of ASCII characters.

In the past, I've implemented a natural sort in JavaScript by creating a "normalized" string that pads the embedded numbers with a fixed-set of Zeros. This works in many cases; but, is complicated and brittle. By using String.prototype.localeCompare(), not only does the code become incredibly simple, the actual numeric collation is more robust.

The base implementation of .localeCompare() compares two strings:

a.localeCompare( b )

... and returns a negative value is "a" sorts before "b"; a positive value if "b" sorts before "a"; and, a zero if "a" and "b" are equal. This dovetails perfectly with the Array.prototype.sort() method, which uses exactly these types of ranges to determine how to sort the items within a collection.

By default, the .localeCompare() method treats embedded numbers as strings. However, in most modern browsers, you can pass a set of options to the method which alters this behavior. In particular, the numeric option will cause embedded numbers to be treated as numbers:

a.localeCompare( b, undefined, { numeric: true } )

Which, is exactly what we need to implement a natural sort in JavaScript.

To see this in action, I've put together a simple Angular 8 demo that allows a collection of string values to be sorted using either a basic comparison or a .localeCompare() comparison. In the following code, all of the "sort operators" are at the top - the Angular code just applies the user-selected operator to the set of strings:


// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// These are the "normal" sort methods that I might code if I was implementing the
// comparison operator manually. These are the "control" operators in this experiment.

function normal( a: string, b: string ) : number {

	if ( a === b ) {

		return( 0 );

	}

	return( ( a < b ) ? -1 : 1 );

}

function normalNoCase( a: string, b: string ) : number {

	return( normal( a.toUpperCase(), b.toUpperCase() ) );

}

function invertedNormal( a: string, b: string ) : number {

	return( -normal( a, b ) );

}

function invertedNormalNoCase( a: string, b: string ) : number {

	return( -normalNoCase( a, b ) );

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// These are the sort methods that are powered by String.prototype.localeCompare(). The
// .localeCompare() method is generally supported, but has extended options that are less
// generally supported. These are the "experimental" operators in this experiment.

function compare( a: string, b: string ) : number {

	// Not all browsers support the extended options for localeCompare(). As such, let's
	// wrap the extended version in a try/catch and just fall-back to using the simple
	// version. In this case, we're going to use the "numeric" option, which gets the
	// browser to treat embedded numbers AS NUMBERS, which allows for a more "natural
	// sort" behavior.
	try {

		return( a.localeCompare( b, undefined, { numeric: true } ) );

	} catch ( error ) {

		console.warn( "Extended localeCompare() not supported in this browser." );
		return( a.localeCompare( b ) );

	}

}

function compareNoCase( a: string, b: string ) : number {

	return( compare( a.toUpperCase(), b.toUpperCase() ) );

}

function invertedCompare( a: string, b: string ) : number {

	return( -compare( a, b ) );

}

function invertedCompareNoCase( a: string, b: string ) : number {

	return( -compareNoCase( a, b ) );

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p class="options">
			<strong>Normal Sort:</strong>
			<a (click)="handleNormalSort( 'asc', false )">ASC</a>
			<a (click)="handleNormalSort( 'asc', true )">ASC No Case</a>
			<a (click)="handleNormalSort( 'desc', false )">DESC</a>
			<a (click)="handleNormalSort( 'desc', true )">DESC No Case</a>
		</p>

		<p class="options">
			<strong>LocaleCompare Sort:</strong>
			<a (click)="handleLocaleCompareSort( 'asc', false )">ASC</a>
			<a (click)="handleLocaleCompareSort( 'asc', true )">ASC No Case</a>
			<a (click)="handleLocaleCompareSort( 'desc', false )">DESC</a>
			<a (click)="handleLocaleCompareSort( 'desc', true )">DESC No Case</a>
		</p>

		<ul>
			<li *ngFor="let value of values">
				{{ value }}
			</li>
		</ul>
	`
})
export class AppComponent {

	public values: string[];

	// I initialize the app component.
	constructor() {

		// NOTE: Part of the value-add of the localeCompare() method is that it can
		// handle non-English ASCII values more naturally. However, since I don't know
		// any of the rules around non-English ASCII, I am only testing English ASCII.
		// This way, I'll be able to interpret the results more easily.
		this.values = [
			// Test case-based sorting for English ASCII.
			"a", "A", "z", "Z",

			// Test embedded number sorting for English ASCII.
			"0", "9", "30", "30.5", "30.30", "500",
			"Item 0", "Item 9", "Item 30 a", "Item 30.5 a", "Item 30.30 a", "Item 500"
		];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I apply one of the localeCompare() sort operators.
	public handleLocaleCompareSort( direction: "asc" | "desc", caseInsensitive: boolean ) : void {

		if ( direction === "asc" ) {

			var operator = caseInsensitive
				? compareNoCase
				: compare
			;

		} else {

			var operator = caseInsensitive
				? invertedCompareNoCase
				: invertedCompare
			;

		}

		this.values.sort( operator );

	}


	// I apply one of the "control case" sort operators.
	public handleNormalSort( direction: "asc" | "desc", caseInsensitive: boolean ) : void {

		if ( direction === "asc" ) {

			var operator = caseInsensitive
				? normalNoCase
				: normal
			;

		} else {

			var operator = caseInsensitive
				? invertedNormalNoCase
				: invertedNormal
			;

		}

		this.values.sort( operator );

	}

}

As you can see, for each set of operators in this experiment, the user has the option to choose a sorting direction - ascending or descending - and whether or not the sort should be case-sensitive.

If we run this Angular code in the browser and choose the Normal ASC sort, we get the following output:

Embedded number are treated as strings when using native comparisons in Angular 8.

As you can see, when we are using the native less-than / greater-than operators, the embedded numbers are treated as strings during the sort operation. However, if we now choose the .localeCompare() ASC sort, we get the following output:

Embedded number are treated as numbers when using the .localeCompare() in Angular 8.

As you can see, with the .localeCompare() method (and the numeric option) powering the sort operation, the embedded numbers are now treated as numbers. This leads to a much more human-friendly, more natural sorting outcome.

ASIDE: Part of the value-add of using the .localeCompare() method is that it is much better at handling non-English characters, especially when upper/lower casing matters. However, since I am primarily familiar with the English language, this portion of the functionality is less obvious to me.

I love the fact that I am constantly learning about new features of the JavaScript language. It really is a wonderful language to work with. And, now that I know about the String.prototype.localeCompare() method, implementing a "natural sort" in my Angular applications is going to be so much easier (and almost certainly more accurate and more performant).



Reader Comments

@All,

Upon further review, we can see that the decimal place of a number is not mathematically correct. This is why I stumbled over it in the Video recording. My brain is not good at maths. We can see that, even with numeric collation, we get:

  • 30.5
  • 30.30

On its own 5 sorts before 30. But, mathematically speaking, .5 is larger than .30. The issue is that localeCompare() is not treating the decimal place as a factional number - it's treating it as an integer that comes after a . character.

You can see this if you add additional string of decimals, example:

  • 30.5.7
  • 30.5.15
  • 30.30.2

In this case, each "column of numbers" is treated as an isolate numeric value. So, in reality, the "numeric collation" is more robust, and I think more in alignment with how a human would think of it.

Reply to this Comment

Interesting. I had to implement a routine in CF, the other day, which needed to sort number ranges like:

{
    '6-10':'formula A ',
    '5-1':'formula B',
    '11-19':'formula C'
}

To:

{
    '5-1':'formula B',
    '6-10':'formula A ',
    '11-19':'formula C'
}

I presume from what you discovered, in relation, to the 'decimal point' issue, that this would not sort, correctly in ascending numerical order, using the start range value. Although, on second thoughts, I am thinking theoretically, that maybe it might work due to the structure of a range. If you remove the hyphen from each range, the numbers seem to maintain their hierarchical integrity?

In the end, I had to split each range into a list, using the hyphen as the delimiter, then add each constituent part into an 2 dimensional array. Then reorder the array and then add the array items into a Hash Map. Because Hash Maps, maintain key order. Quite long winded, but it did the job.

It would have been nice, if I could have used something like:

localeCompare()

To reduce the complexity of such a routine.

Reply to this Comment

@Charles,

Actually, I think localeCompare() would work for your set of keys, since your ranges are essentially in the pattern of:

number-separator-number

This seems to be exactly how localeCompare() wants to apply the numeric collation. So, it would sort based on the first number, then on the second number, which seems like it would work.

That said, localeCompare() is in JavaScript, and it sounds like you're doing the sorting in ColdFusion, so we're comparing apples to oranges :D If you're doing this in ColdFusion, it sounds like you'd still need a custom solution.

Reply to this Comment

RE: number-separator-number

Yes. This is what I thought.

I could use the Coldfusion 'natural sorting' routine from your previous post!

I think this would be better than the complicated approach, I am using below:

<cffunction name="SortMembershipFormula" returntype="struct" access="public" output="no" hint="function description: sort membership formula">
    <!--- arguments --->
    <cfargument name="structure" type="struct" required="no" default="#StructNew()#" hint="argument description: structure">
    <!--- local variables --->
    <cfset var result = createObject("java","java.util.LinkedHashMap").init()>
    <cfset var local = StructNew()>
    <!--- logic --->
    <cfset local.array = ArrayNew(2)>
    <cfset local.counter = 1>
    <cfloop collection="#arguments.structure#" item="local.key">
	  <cfset local.startRange = ListFirst(local.key,"-")>
      <cfset local.endRange = ListLast(local.key,"-")>
      <cfset ArrayAppend(local.array[local.counter],local.startRange)>
      <cfset ArrayAppend(local.array[local.counter],local.key)>
      <cfset ArrayAppend(local.array[local.counter],arguments.structure[local.key])>
      <cfset local.counter = local.counter + 1>
    </cfloop>
    <cfif ArrayLen(local.array)>
	  <cfset local.array = getArrayService().ArraySort2D(local.array,1,"numeric","asc")>
      <cfloop from="1" to="#ArrayLen(local.array)#" index="local.index">
		<cfset result[local.array[local.index][2]] = local.array[local.index][3]>
      </cfloop>
    </cfif>
    <cfreturn result>
</cffunction>

<cfset formula = '{
    "1-9":"n*2*0.75",
    "10-19":"n*2",
    "20-29":"n*2*0.75",
    "30-300":"(n*a)/(LOG(a)/(LOG(1.35)))",
    "301-1000":"(n*a)/(LOG(a)/(LOG(1.35)))*0.9"
}'>
  
<cfset formulaStruct = DeserializeJson(formula)>

<!--- 

I need to convert the Struct into a Linked Hash Map, because Structs in Coldfusion do not make any guarantees about what order its keys are in

 --->

<cfset sortMembershipFormula = SortMembershipFormula(structure=formulaStruct)>
  
<cfdump var="#sortMembershipFormula#" />

Reply to this Comment

@Charles,

I feel like I had an algorithm like that for something in the past. I vaguely remember having to sort arrays-of-arrays, where each array index needed to be compared to the same index in the other arrays. I can't for the life of me remember what it was for -- but, I remember the .sort() method (this was JavaScript) being fairly complicated :D

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.