JavaScript Type Checking... Without TypeScript

Adam Nathaniel Davis - Mar 8 '20 - - Dev Community

[NOTE: The concepts discussed in this article eventually evolved into a new approach with slightly different terminology. That approach now comprises a package that I call allow. You can find it here: https://www.npmjs.com/package/@toolz/allow]

There seems to be two crowds in the JavaScript community: those who use type-checking, and those who don't. If you read that last sentence as "...those who use TypeScript, and those who don't" you can be forgiven for reading a little more into the text than what was actually written. Because, far too often, projects that don't use TypeScript have an appalling lack of type-checking in place.

That's why I wrote this concise little utility that you can find here:

https://github.com/bytebodger/type-checking

Go ahead and pop on over there when you have a chance. It's only one file (is.js). It's all of 84 LoC. But I use this little utility on an incredibly frequent basis.

[Disclaimer: As you can imagine, with only 84 LoC, I'm not implying, in any way, that my silly little utility is any kind of replacement for TypeScript. If you want/need true type checking in your project, by all means, please reach for TypeScript (or Flow). This is just a helpful utility for those times when you're working inside a project that does not - or cannot - use TypeScript.]

The Problem

Nearly all of the programs that we write aren't actually singular, standalone programs. Instead, our programs consist of dozens/hundreds/thousands of miniature programs which, in aggregate, make up our application. You know what I'm talking about. These smaller component programs are known as functions.

Each function is a (hopefully) tiny program in its own right. It accepts an arbitrary list of zero-to-many inputs. It returns a single output - or it generates no output at all. Everything that happens inside that function operates as its own little program.

Now I'm a big believer that type mutability in dynamically-typed languages is a feature, not a "bug". If you want absolute certainty about all the types of all your variables at all times, then you shouldn't be programming in JavaScript in the first place. There are statically-typed languages that are there for the taking. And I can sometimes get kinda annoyed by the people who want to do everything they can to make JavaScript look/feel/act like C#.

But there's one area where I strongly believe that type certainty isn't a preference or a nice-to-have. This area is in the interface between functions. It's a must, if we're to write solid, robust, bug-free functions. In other words, it's nearly impossible to assure that our "mini-programs" (functions) will operate properly if we have no idea what type of arguments are being passed into them.

The Problem, Illustrated

const updateUser = (userId, name, age, currentEmployee, children) => {
   // the update logic...
   return updateResult;
};
Enter fullscreen mode Exit fullscreen mode

Despite the simple nature of this function, there's really a lot that's potentially going on here. To update the user, we're accepting five separate arguments. Without taking the time to delve through any logic that might be inside the function, there are numerous questions that crop up:

  1. Is userId supposed to be an integer? Or are we using some kind of alphanumeric (GUID) identifier, in which case this would be a string?

  2. I assume that name should be a string, although it's not out-of-the-question to believe that the function expects name to be an object. Perhaps one that is formatted like so: {first:'Tom', middle:'Tim', last:'Tum'}. Or maybe an array, like: ['Tom','Tim','Tum'].

  3. I assume that age should be an integer, but will it accept decimals? Will it accept 0?

  4. Maybe currentEmployee is supposed to be a Boolean? Or maybe it's a string that contains the name of the user's employee? There's no way to know for certain.

  5. Children "feels" like it should be an array - but again, there's no way to know that from the function signature.

So here we have two potential headaches:

There's little-to-no self-documentation going on in this function, so anyone invoking it has to either A. burn precious time reading through the entire function code to know exactly what's expected for each argument, or B. make a best-guess based upon the names of the arguments themselves.

And...

It's extremely difficult to write a robust function that will accept any kind of input for any of these five arguments without throwing an error or returning an aberrant value. What happens if I pass in an object for userId? Or an array for age? Will the code fail gracefully?

(A Little) Help With Default Values

We can make this somewhat cleaner and easier to understand if we add default values to our arguments, like so:

const updateUser = (userId = 0, name = '', age = 0, currentEmployee = false, children = []) => {
   // the update logic...
   return updateResult;
};
Enter fullscreen mode Exit fullscreen mode

This definitely helps the casual developer to quickly grasp the types of values that should be passed into this function. We no longer have to guess about things like integer-vs-GUID userIds.

But this does almost nothing to ensure the proper execution of the function itself. That's because default values will only dictate the data type when no value is supplied. If the caller does, in fact, provide a value for the argument, the supplied value is used, regardless of whatever data type is implied by the default values.

To put this in practical terms, the default argument values don't stop us from doing this:

const updateUser = (userId = 0, name = '', age = 0, currentEmployee = false, children = []) => {
   // the update logic...
   return updateResult;
};

updateUser('007', {first:'Joe', last:'Blow'}, 'not saying', ['sure'], false);
Enter fullscreen mode Exit fullscreen mode

In this case, we've made a real mess of the function invocation by chunking in a whole bunch of mismatched data types that our function probably wasn't expecting. It doesn't matter that the default values implied certain data types. Since we actually supplied our own data, JavaScript allowed us to pass in any data type we chose.

Here's another way that we can potentially screw up this function:

const updateUser = (userId = 0, name = '', age = 0, currentEmployee = false, children = []) => {
   // the update logic...
   return updateResult;
};

updateUser(0, '', 0);
Enter fullscreen mode Exit fullscreen mode

Technically, we provided the function with the correct data types that are implied in the default values. But even though we accurately supplied integer \ string \ integer, there's a very good chance that this function invocation could fail or spawn some other kind of bug. Because, while 0, '', 0 definitely satisfies the "spirit" of the function call, there's a very good chance that 0 is an invalid integer to use for userId, that '' (empty string) is an invalid value to use for name, and that 0 is an invalid value to use for age.

So unless the logic inside the function is robust, this might spawn some kind of error or bug - even though we technically passed the proper data types into the function call.

At this point, I can almost hear some of you thinking:

None of this matters, because I'd never call my own function with the wrong types of data/values.

And that's great. I'm glad that your own coding is perfect and flawless. But once you've committed/merged the code for your function, you never technically know who's going to write new code (or alter existing code) to call that function. In other words, once you put your function out there, into the wild, it has to stand on its own. It needs to be as robust, bug-free, and foolproof as possible.

The proper execution of your function should never be dependent upon the idea that the caller will invoke it in the "proper" way.

If there is any "downside" to functional programming, it's that you, as the function's writer, can control anything that happens inside the function. But you can't control how/when it's called.

This is why I believe that JavaScript's dynamic typing is only a critical problem at the entrypoint to functions. Because most functions depend upon the data being presented in a certain format, and of a certain type.

Sure... it's possible to write all the logic inside the function that you need to handle any-and-all types of inputs, but that can be overly laborious and bloat our otherwise sleek-and-efficient functions.

One Potential Solution

As stated above in the disclaimer, the full/official/accepted way to address this is to use a heavy-duty strongly-typed system, like TypeScript or Flow. But that's not always an option. Sometimes you may not want to go to that extreme. Other times, you simply may not have the option to add something like TypeScript to a legacy project.

So are you stuck writing brittle functions? Or writing bloated functions that painstakingly try to account for every possible input? Hardly. The repo that I linked to at the top of this article shows my homegrown solution.

It's really just a single file. A class that I export and use as is. I chose this name because it's very short, and it maps to the values that I expect back from all of is's functions. You see, every validation in the file returns a Boolean. Every function checks to see whether a value conforms to a certain type.

In practical application, it looks like this:

import is from './is';

const updateUser = (userId = 0, name = '', age = 0, currentEmployee = false, children = []) => {
   if (!is.aPositiveInteger(userId) || !is.aPopulatedString(name) || !is.aPositiveInteger(age) || !is.aBoolean(currentEmployee) || !is.anArray(children))
      return;
   // the update logic...
   return updateResult;
};
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • If this looks a little wordy, please keep in mind that most functions have only one-or-two arguments. The only reason this looks longer is because there are five separate arguments to check.

  • In the example above, I'm just bailing out of the function if any of the checks fails with a simple return;. Obviously, depending upon the logic in your function, you may choose to follow a failed check with something like return false; or return null;.

  • I try to make the checks as specific as possible to the data that's needed inside the function. For example, I don't do if (!is.anInteger(userId))... because userId should really be a positive integer, and we don't want to have a value like 0 or -482 passed in. For the name value, we only want a populated (non-empty) string. It's not enough just to ensure that the supplied value is a string - because the empty string is still, technically, a string. But the empty string is not a valid value. But we're more lenient with regard to children. Because it's perfectly fine for children to consist of nothing but an empty array.

  • Whenever one of these checks fails, it will throw a console.error() message for you to see in the dev tools.

  • Notice that an argument's default value, combined with the is.() check on the next line, tells us whether the argument is truly required. We are supplying a default value for userId of 0. But the is() check ensures that the value is greater than zero. This means, functionally speaking, that it's required for the caller to supply a userId value. But children is not required. It has a default value of [] and the is() check only ensures that the value is, indeed, an array. So the function can be called without supplying any value for children.

  • There's certainly room to expand the list of validation in is.js. For example, a function could be created to ensure that a value is an array of strings, or an array of integers, or an array of objects. Of course, the more time you spend building out the validations in is.js, the more you have to ask yourself whether you should just be using a robust tool - like TypeScript. So don't go too overboard with this.

Implementation

It's fairly self-explanatory. But here are two tips that I use whenever deploying this in a non-TypeScript project:

  1. Every argument, in every function, comes with a default value assigned.

  2. The first line inside every function (that accepts arguments) consists of the is() checks needed to ensure that the supplied values conform with their expected data type.

That's it. No other "rules" to abide by. I hope this approach helps someone else as well.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player