Better TypeScript... With JavaScript

bytebodger

Adam Nathaniel Davis

Posted on July 27, 2020

Better TypeScript... With JavaScript

[NOTE: The library that I reference throughout this post - allow - is now available in an NPM package. You can find it here: https://www.npmjs.com/package/@toolz/allow]

In my previous post (https://dev.to/bytebodger/tossing-typescript-1md3) I laid out the reasons why TypeScript is, for me, a big #FAIL. A lot of extra work in return for a false sense of security and few tangible benefits.

I won't rehash those arguments again. You can browse through that article if you're interested. In this article, I'll be outlining my practical-and-tactical solution in a purely-JavaScript environment.

FWIW, I wrote an article somewhat-similar to this one back in March (https://dev.to/bytebodger/javascript-type-checking-without-typescript-21aa). While the basis of my approach hasn't changed radically, the specifics of my implementation are quite different.

All of the code for this article can be referenced in this single file:

https://github.com/bytebodger/spotify/blob/master/src/classes/allow.js

It's part of my Spotify Toolz project, although I'll also be porting it into my type-checking library.


Alt Text

Type-Checking Goals

Without rehashing content from my previous articles, suffice it to say that there are several key factors that I find important in type-checking:

  1. I care almost exclusively about ensuring type safety at runtime. Telling me that your app compiled means almost nothing to me. Your app compiled. I tied my shoes. We didn't drive off a cliff. Do we all get cookies?? If my app compiles, that's no guarantee that it runs. If my app runs, it's guaranteed to compile. So I focus on runtime.

  2. I care almost exclusively about ensuring type safety at the interfaces between apps. Those could be interfaces between my app and some outside data source - e.g., an API. Or it could be the interface between one function and another. It doesn't matter if the exchange reaches outside my app, or whether the exchange is entirely encapsulated by the app. The point is that, if I know I'm getting "clean" inputs, there's a much greater likelihood that any logic I've written inside the app will perform as expected.

  3. Type-checking should be clean. Fast. Efficient. If I have to spend countless hours trying to explain functioning code to a compiler, then that type-checking is more of a hurdle than a feature. This also means that type-checking should be as complete as it needs to be - and no more. In other words, if I'm receiving an object from an API response that contains 100 keys, but I'm only using 3 of those keys, then I shouldn't have to define the other 97.

  4. "Defensive programming" should be kept to a minimum. In my previous post, @somedood made a good point about the headaches of having to use a continual stream of if checks to ensure that we've received proper data. I thoroughly understand this. Any solution that requires constantly writing new if checks is - a non-solution.


Alt Text

The Basic Approach

In my previous article, I outlined one scenario where we could be passing in a number - but would still need to check inside the function to ensure that the argument is, indeed, a number. The scenario looks like this:

const createId = (length = 32) => {
  if (isNaN(length)) length = 32;
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

The simple fact is that, as long as we're targeting runtime issues, there really is no way around this. That's why I focus nearly all of my validations on runtime validations. Because I'm not interested in the faux-security that comes with successful compilation.

I want to know, in real-time, whether something will fail at runtime.


So my "answer" to this problem is that, if I can't eliminate the inside-the-function-body validations, I at least want to make them clean, fast, and efficient. With no manual need to craft fancy if conditions.

In the code linked-to above, I have a basic validation class that I've called allow. allow contains a series of methods that check for various data types.

One key difference in my new approach is that each method is chained. This means that I can perform all of my validations with a single line of code. So whether a function has one argument or a dozen, I don't have copious LoC inside the function spent on validating those inputs.

Another difference is that my latest approach doesn't return any validation values. The methods simply throw on error or... nothing happens. Which is exactly what I want to happen.

Of course, the code can be tweaked so that, in production, the "failure" results in some kind of silent error. But the key is that, if a function receives "bad" data, then I want that function to bail out in some way.

So the following examples will all look similar to this:

const myFunction = (someBoolean = false, someString = '') => {
  allow.aBoolean(someBoolean).aString(someString);
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

The Simplest Validations

I call these "simple" because there's nothing to do but to pass in the value and see if it validates. They look like this:

// booleans
const myFunction = (someBoolean = false) => {
  allow.aBoolean(someBoolean);
  // rest of function...
}

// functions
const myFunction = (someCallback = () => {}) => {
  allow.aFunction(someCallback);
  // rest of function...
}

// React elements
const myFunction = (someElement = <></>) => {
  allow.aReactElement(someElement);
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

Nothing too magical about these. aBoolean(), aFunction(), and aReactElement() will all fail if they do not receive their respective data types.


Enums

Enums can be checked against a simple array of acceptable values. Or you can pass in an object, in which case the object's values will be used to gather the acceptable values.

// one of...
const statuses = ['open', 'closed', 'hold'];

const myFunction = (status = '') => {
  allow.oneOf(status, statuses);
  // rest of function...
}

const colors = {
  red: '#ff0000',
  green: '#00ff00',
  blue: '#0000ff',
}
const myFunction = (color = '') => {
  allow.oneOf(color, colors);
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Strings

The simplest way to validate strings is like so:

// string
const myFunction = (someString = '') => {
  allow.aString(someString);
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

But often, an empty string is not really a valid string, for the purposes of your function's logic. And there may be other times when you want to indicate a minLength or a maxLength. So you can also use the validation like so:

// strings
const myFunction = (someString = '') => {
  allow.aString(someString, 1);
  // this ensures that someString is NOT empty
  // rest of function...
}

const myFunction = (stateAbbreviation = '') => {
  allow.aString(stateAbbreviation, 2, 2);
  // this ensures that stateAbbreviation is EXACTLY 2-characters in 
  // length
  // rest of function...
}

const myFunction = (description = '') => {
  allow.aString(description, 1, 250);
  // this ensures that description is not empty and is <= 250 
  // characters in length
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Numbers

Like strings, numbers can be simply validated as being numerical-or-not. Or they can be validated within a range. I also find that I rarely use allow.aNumber() but I frequently use allow.anInteger(). Because, in most cases where I'm expecting numbers, they really should be integers.

// numbers
const myFunction = (balance = 0) => {
  allow.aNumber(balance);
  // can be ANY number, positive or negative, integer or decimal
  // rest of function...
}

const myFunction = (age = 0) => {
  allow.aNumber(age, 0, 125);
  // any number, integer or decimal, >= 0 and <= 125
  // rest of function...
}

const myFunction = (goalDifferential = 0) => {
  allow.anInteger(goalDifferential);
  // any integer, positive or negative
  // rest of function...
}

const myFunction = (id = 0) => {
  allow.anInteger(id, 1);
  // any integer, >= 1
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Objects

This is not for defining specific types of objects. We'll cover that with anInstanceOf. This only checks whether something fits the definition of being a generic "object" and, if you desire, whether the object is of a certain "size".

This also excludes null (which JavaScript classifies as an object) and arrays (which are also, technically, objects). You'll see that there's a whole set of validations specifically for arrays in a minute.

// objects
const myFunction = (user = {}) => {
  allow.anObject(user);
  // can be ANY object - even an empty object
  // rest of function...
}

const myFunction = (user = {}) => {
  allow.anObject(user, 1);
  // this doesn't validate the shape of the user object
  // but it ensures that the object isn't empty
  // rest of function...
}

const myFunction = (user = {}) => {
  allow.anObject(user, 4, 4);
  // again - it doesn't validate the contents of the user object
  // but it ensures that the object has exactly 4 keys
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Instances

These validate the shape of an object. Please note that they don't validate the data types within that shape. Could it be extended to provide that level of validation? Yes. Do I require that level of validation in my personal programming? No. So right now, it just concentrates on the existence of keys.

It will also validate recursively. So if you have an object, that contains an object, that contains an object, you can still validate it with anInstanceOf().

anInstanceOf() requires an object, and a "model" object against which to check it. Every key in the model is considered to be required. But the supplied object can have additional keys that don't exist in the model object.

// instance of...
const meModel = {
  name: '',
  address: '',
  degrees: [],
  ancestors: {
    mother: '',
    father: '',
  },
}

let me = {
  name: 'adam',
  address: '101 Main',
  degrees: [],
  ancestors: {
    mother: 'mary',
    father: 'joe',
  },
  height: '5 foot',
}

const myFunction = (person = meModel) => {
  allow.anInstanceOf(person, meModel);
  // rest of function...
}
myFunction(me);
// this validates - me has an extra key, but that's ok
// because me contains all of the keys that exist in 
// meModel - also notice that meModel is used as the 
// default value - this provides code-completion clues
// to your IDE

let me = {
  name: 'adam',
  degrees: [],
  ancestors: {
    mother: 'mary',
    father: 'joe',
  },
  height: '5 foot',
}
myFunction(me);
// this does NOT validate - me is missing the address
// key that exists in meModel
Enter fullscreen mode Exit fullscreen mode

 

Arrays

The simplest validation is just to ensure that a value is an array. Along with that validation, you can also ensure that the array is not empty, or that it is of a specific length.

// arrays
const myFunction = (someArray = []) => {
  allow.anArray(someArray);
  // rest of function...
}

const myFunction = (someArray = []) => {
  allow.anArray(someArray, 1);
  // this ensures that someArray is NOT empty
  // rest of function...
}

const myFunction = (someArray = []) => {
  allow.anArray(someArray, 2, 2);
  // this ensures that someArray contains EXACTLY 2 elements
  // rest of function...
}

const myFunction = (someArray = []) => {
  allow.anArray(someArray, 1, 250);
  // this ensures that someArray is not empty and is <= 250 
  // elements in length
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Arrays Of...

It's often insufficient merely to know that something is an array. You may need to ensure that the array contains elements of a particular data type. In other words, you have arrays of integers, or arrays of strings, etc.

All of these come with minLength/maxLength optional arguments, so you can ensure that the arrays are non-empty, or are of a particular size.

// array of arrays
const myFunction = (someArray = [[]]) => {
  allow.anArrayOfArrays(someArray);
  // rest of function...
}

// array of instances
const myFunction = (someArray = [meModel]) => {
  allow.anArrayOfInstances(someArray, meModel);
  // rest of function...
}

// array of integers
const myFunction = (someArray = [0]) => {
  allow.anArrayOfIntegers(someArray);
  // rest of function...
}

// array of numbers
const myFunction = (someArray = [0]) => {
  allow.anArrayOfNumbers(someArray);
  // rest of function...
}

// array of objects
const myFunction = (someArray = [{}]) => {
  allow.anArrayOfObjects(someArray);
  // rest of function...
}

// array of strings
const myFunction = (someArray = ['']) => {
  allow.anArrayOfStrings(someArray);
  // rest of function...
}
Enter fullscreen mode Exit fullscreen mode

 

Real-World Examples

In my Spotify Toolz app, I'm currently using this runtime type-checking. You can view that code here:

https://github.com/bytebodger/spotify

But here are some examples of what they look like in my functions:

const getTrackDescription = (track = trackModel, index = -1) => {
  allow.anInstanceOf(track, trackModel).anInteger(index, is.not.negative);
  return (
     <div key={track.id + index}>
        {index + 1}. {track.name} by {getTrackArtistNames(track)}
     </div>
  );
}

const comparePlaylists = (playlist1 = playlistModel, playlist2 = playlistModel) => {
  allow.anInstanceOf(playlist1, playlistModel).anInstanceOf(playlist2, playlistModel);
  if (playlist1.name.toLowerCase() < playlist2.name.toLowerCase())
     return -1;
  else if (playlist1.name.toLowerCase() > playlist2.name.toLowerCase())
     return 1;
  else
     return 0;
};

const addPlaylist = (playlist = playlistModel) => {
  allow.anInstanceOf(playlist, playlistModel);
  local.setItem('playlists', [...playlists, playlist]);
  setPlaylists([...playlists, playlist]);
}

const addTracks = (playlistId = '', uris = ['']) => {
  allow.aString(playlistId, is.not.empty).anArrayOfStrings(uris, is.not.empty);
  return api.call(the.method.post, `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, {uris});
}
Enter fullscreen mode Exit fullscreen mode

Every function signature is given runtime validation with a single line of code. It's obviously more code than using no validations. But it's far simpler than piling TS into the mix.
 

Conclusion

Does this replace TypeScript?? Well... of course not. But this one little library honestly provides far more value, to me, than a vast majority of the TS code that I've had to crank out over the last several months.

I don't find myself "fighting" with the compiler. I don't find myself having to write compiler checks and runtime checks. I just validate my function signatures and then I write my logic, content in the knowledge that, at runtime, the data types will be what I expect them to be.

Perhaps just as important, my IDE "gets" this. For example, when I define an object's model, and then use it as the default value in a function signature, I don't have to tell my IDE that the user object can contain a parents object, which can contain a mother key and a father key.

You may notice that there are empirical limits to the type-checking that I'm doing here. For example, I'm validating the shape of objects, but I'm not validating that every key in that object contains a specific type of data. I might add this in the future, but I don't consider this to be any kind of "critical flaw".

You see, if I'm passing around shapes, and I can validate that a given object conforms to the shape that I require, there's often little-to-no worry that the data in those shapes is "correct". Typically, if I've received a "bad" object, it can be detected by the fact that the object doesn't conform with the necessary shape. It's exceedingly rare that an object is of the right shape - but contains unexpected data types.

💖 💪 🙅 🚩
bytebodger
Adam Nathaniel Davis

Posted on July 27, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related