Logical Blocking in Code

rachaeldawn

Rachael Dawn

Posted on April 27, 2023

Logical Blocking in Code

Take a second to think about the nature of describing functionality in code, all the common structures you use to accomplish your task, and all the patterns you reuse. In the most common programming languages you'll see functions, if-else trees, switch statements, try-catch, and other structures that people use to describe functionality using code. As you gain experience with writing code, you start to notice patterns in how you express code and the common tasks that you end up doing in your functions.

I'll use Typescript for all of my examples to come because it is easy to read, and most people can understand what is going on.

Once you start noticing these common patterns, you will start to fall back on a specific way of writing your code. However, I want to propose a way of thinking about these patterns. I call these smallest-form of connected logical expressions "Logical Blocks", and you'll see why. Consider what happens when you read spaghetti code. It's called spaghetti code because it goes in and out, you struggle to tell where the function ends, you can't tell where some variable stupidly called x was changed, and for some stupid reason it's a 200 line function. What went wrong? How did that developer end up creating that?

Chances are they are overcomplicating the problem in their head or not separating their logic properly. Logical Blocking is the act of isolating one specific idea or intent visually, and reusing a specific pattern in appropriate fashions to accomplish your tasks. A Logical Block is a single "unit" of logic that you can use. To demonstrate what this is, and how to think in terms of logical blocks, I want to go through my personal process of refactoring code that I don't like. The goal is to validate a user.

Before you read this, I have seen code like this in Production environments before, so this is a realistic scenario.

function validateUser (user: any): string | null {
  if (user) {
    if (typeof user !== 'object') {
      return 'User must be an object';
    } else {
      if (!user.hasOwnProperty('name') || typeof user.name !== 'string') {
        return 'User must have a valid name property';
      }

      if (!user.hasOwnProperty('email') || typeof user.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
        return 'User must have a valid email property';
      }

      if (!user.hasOwnProperty('age') || typeof user.age !== 'number' || user.age < 18 || user.age > 99) {
        return 'User must have a valid age property';
      }

      if (!user.hasOwnProperty('address') || typeof user.address !== 'object' || !user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
        return 'User must have a valid address property';
      }

      if (!user.hasOwnProperty('phone') || typeof user.phone !== 'string' || !/^\d{3}-\d{3}-\d{4}$/.test(user.phone)) {
        return 'User must have a valid phone property';
      }

      return null;
    }
  } else {
    return 'User is undefined or null';
  }
};
Enter fullscreen mode Exit fullscreen mode

I don't like reading that, and neither do you. But, it has a lot of usable code. Let's follow some basic rules to clean this up. First, we will start by looking for ways we can reduce indentation by using early returns. The first thing to note is that the entire function is wrapped in an if-statement, which is bad bad bad. Let's fix that quick.

Early Returns

function validateUser (user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  } else {
    if (!user.hasOwnProperty('name') || typeof user.name !== 'string') {
      return 'User must have a valid name property';
    }

    if (!user.hasOwnProperty('email') || typeof user.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
      return 'User must have a valid email property';
    }

    if (!user.hasOwnProperty('age') || typeof user.age !== 'number' || user.age < 18 || user.age > 99) {
      return 'User must have a valid age property';
    }

    if (!user.hasOwnProperty('address') || typeof user.address !== 'object' || !user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
      return 'User must have a valid address property';
    }

    if (!user.hasOwnProperty('phone') || typeof user.phone !== 'string' || !/^\d{3}-\d{3}-\d{4}$/.test(user.phone)) {
      return 'User must have a valid phone property';
    }

    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode

We have created our first logical block. Where was it?

  if (user == null) {
    return 'User is undefined or null';
  }
Enter fullscreen mode Exit fullscreen mode

This is an entire idea has been "blocked" away visually. When you're reading code, you want to be aware of how your eyes drift through the code, and you want to enable the next developer who reads your code (which might very well be you!) to be able to filter out the code they do not care about. If there is a bug, or there is an optimization that needs to be made, having Logical Blocks allows your brain to choose to completely ignore the entire if statement and move onto the next chunks of code.

Let's continue this pattern and remove the next way-too-large chunk of code. You'll notice what I call a "useless else". It's useless because it doesn't change how the code works, but it sure makes it more difficult to tell what's going on.

function validateUser (user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  } 

  if (!user.hasOwnProperty('name') || typeof user.name !== 'string') {
    return 'User must have a valid name property';
  }

  if (!user.hasOwnProperty('email') || typeof user.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
    return 'User must have a valid email property';
  }

  if (!user.hasOwnProperty('age') || typeof user.age !== 'number' || user.age < 18 || user.age > 99) {
    return 'User must have a valid age property';
  }

  if (!user.hasOwnProperty('address') || typeof user.address !== 'object' || !user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
    return 'User must have a valid address property';
  }

  if (!user.hasOwnProperty('phone') || typeof user.phone !== 'string' || !/^\d{3}-\d{3}-\d{4}$/.test(user.phone)) {
    return 'User must have a valid phone property';
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

At this point, it looks tolerable. It could be better, but it's okay. It will do what we want, but it's not very reusable. This gets into the second part of what Logical Blocking is supposed to achieve: Logical Blocking should make your code read more like common language.

Reusing Your Basic Tasks

The code so far has a significant amount of repeated code that can be abstracted and turned into something that provides value for other developers.

  1. There are a lot of checks to see if a property exists on an object
  2. Each property is checked for its type
  3. Each property is checked for whether it falls within a valid range of values.

We're going to break each of those down into helper functions that can be tested separately, and reused if we ever need it again. The first thing we will do is create a function that checks for all the missing keys in a user object.

// missingObjectProperties will return an array of all the properties that are missing from the
// object provided
function missingObjectProperties<T extends object>(obj: T, keys: Array<keyof T>): Array<keyof T> {
  const missing: Array<keyof T> = [];

  for (const key of keys) {
    // a one-liner "early return"
    if (obj.hasOwnProperty(key)) continue;

    missing.push(key);
  }

  return missing;
}
Enter fullscreen mode Exit fullscreen mode

A quick side-note.

In this example I am using an if-statement without braces. If you are cancelling execution and it is obvious that you are cancelling execution, then you can safely exclude braces. Under no circumstances should your if statement obscure actions taken. Ever. You do not want to be the reason that someone breaks production because you forgot braces.

In the example, you can see that I am simply iterating through all the keys in an object based on an array, and returning the result. This is a remarkably common block you can use in your arsenal. The block is simple:

"I have a type of thing or array of things, and I want to get many of a specific piece of info from it".

That's an entire thought, and under most circumstances you can abstract the entire thought to a new function! Now you can test that you executed that thought properly with its own set of tests, and you can reuse it for many different types of objects in the future!

How do we use it?

function validateUser (user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  }

  const requiredKeys = ['name', 'email', 'age', 'address', 'phone'];
  const missingKeys = missingObjectProperties(user, requiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `User is missing one or more required keys: ${joined}`;
  }

  if (typeof user.name !== 'string') {
    return 'User must have a valid name property';
  }

  if (typeof user.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
    return 'User must have a valid email property';
  }

  if (typeof user.age !== 'number' || user.age < 18 || user.age > 99) {
    return 'User must have a valid age property';
  }

  if (typeof user.address !== 'object' || !user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
    return 'User must have a valid address property';
  }

  if (typeof user.phone !== 'string' || !/^\d{3}-\d{3}-\d{4}$/.test(user.phone)) {
    return 'User must have a valid phone property';
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

A key thing to note: There is now a difference in how results are formed. You are no longer validating each property and breaking on each different invalid property. You are evaluating all the properties as a whole, and telling the user about it when it comes to missing properties on that object. With proper validation, you want to validate the entire value and report every way it is wrong in an object, array, or some other form that you can use to inspect how the validation failed.

The next thing that happens is that we are checking the datatype, and the range of a property one at a time in a series of if statements. We'll tackle the datatype in our next function in a very similar fashion. You'll notice that we encounter the exact same logical block with this abstraction.

// invalidPropertyTypes takes an object of any type, and an object of key -> type. This is a
// generic function, and seems overly complex, but there's a reason for this.
//
// T extends Object -- the obj MUST be an object of some kind so we can check its properties
//
// R extends Record<keyof T, string> -- This is an object where the only valid keys are also
// keys that you can find in whatever T is. This is for your benefit as the developer, so you
// do not accidentally put keys that are invalid. It is NOT Record<keyof T, string> in the
// situation that you have optional keys.
function invalidPropertyTypes<T extends object, R extends Record<keyof T, string>>(
  obj: T
  types: R,
): Array<keyof T> {
  const invalidKeys: Array<keyof T> = [];

  // this is not using Object.entries because there are situations in which keys are broken
  // by the Object.entries function
  for (const key in types) {
    const keyType = types[key];
    if (typeof obj[key] === keyType) continue;

    invalidKeys.push(key);
  }

  return invalidKeys;
}
Enter fullscreen mode Exit fullscreen mode

How about usage? Well again, you'll notice something very similar.

  const invalidTypes = invalidPropertyTypes(user, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidTypes.map(a => {
      return `Expected user.${a} to be of type ${propertyTypes[a]}, received ${typeof user[a]}`;
    });

    return `User has one or more invalid property types.\n${messages.join('\n')}`;
  }
Enter fullscreen mode Exit fullscreen mode

Condensing Syntax

Uh oh! Keeping the line length to 100 characters, we are forced to use braces in a sort of return statement, and we are losing clarity. It is not easy for a reader to see what's going on because now we have multiple return statements back in close proximity, multiple braces, and we've added an unnecessary indentation. How do we fix this? Easy! We reuse other code we've already written.

function invalidPropertyTypeErrors<T extends object, R extends Record<keyof T, string>>(
  name: string,
  obj: T,
  types: R,
): string[] {
  return invalidPropertyTypes(obj, types)
    .map(key => [ `${name}.${key}`, types[key], obj[key] ])
    .map(([prop, expected, actual]) => `Expected ${prop} to be of type ${expected}, is ${actual}`);
}
Enter fullscreen mode Exit fullscreen mode

This function is almost there, but we can make it a little easier to flow through when reading. There's a few things that are unnecessary:

  1. We are using function syntax, but only expressing only a single piece of code.
  2. It's very visually noisy, and the indentations don't help you focus.
  3. It's difficult to break it up in your mind to see what's happening.
const invalidPropertyTypeErrors =
  <T extends object, R extends Record<keyof T, string>>(name: string, obj: T, types: R) =>
    invalidPropertyTypes(obj, types)
      .map(key => [ `${name}.${key}`, types[key], obj[key] ])
      .map(([key, expected, actual]) => `Expected ${key} to be of type ${expected}, is ${actual}`);
Enter fullscreen mode Exit fullscreen mode

Much better! This is a style choice, but here's what happens when you read this function. You see const invalidPropertyTypeErrors and the generic syntax below it. That tells you that you're dealing with some kind of unknown object type, but it also means it's clearly a function. Your eyes likely don't scan to the arguments, and instead go to the next line. You glance at the call to invalidPropertyTypes, and look immediately to the first .map. You see you're getting some key variable, then accessing a name, types, and obj variable somewhere. If you're like me, you glance at the arguments quick before moving to the next line and seeing that you're creating an array of messages based on an object's invalid key types.

Now that you know what this does, your eyes will naturally refuse to see the actual body of the function. You don't need to see it anymore. Once you see the identifier invalidPropertyTypeErrors, you can gloss your eyes over and move on without a second thought. Neat, eh?!

Now we can update our initial usage to be cleaner, and read more like common language.

  const invalidTypes = invalidPropertyTypes(user, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('user', user, propertyTypes);
    return `User has one or more invalid property types.\n${messages.join('\n')}`;
  }
Enter fullscreen mode Exit fullscreen mode

The updated code with all of our changes now becomes:


// missingObjectProperties will return an array of all the properties that are missing from the
// object provided
function missingObjectProperties<T extends object>(obj: T, keys: Array<keyof T>): Array<keyof T> {
  const missing: Array<keyof T> = [];

  for (const key of properties) {
    // a one-liner "early return"
    if (obj.hasOwnProperty(key)) continue;

    missing.push(key);
  }

  return missing;
}

// invalidPropertyTypes takes an object of any type, and an object of key -> type. This is a
// generic function, and seems overly complex, but there's a reason for this.
//
// T extends Object -- the obj MUST be an object of some kind so we can check its properties
//
// R extends Record<keyof T, string> -- This is an object where the only valid keys are also
// keys that you can find in whatever T is. This is for your benefit as the developer, so you
// do not accidentally put keys that are invalid. It is NOT Record<keyof T, string> in the
// situation that you have optional keys.
function invalidPropertyTypes<T extends object, R extends Record<keyof T, string>>(
  obj: T
  types: R,
): Array<keyof T> {
  const invalidKeys: Array<keyof T> = [];

  // this is not using Object.entries because there are situations in which keys are broken
  // by the Object.entries function
  for (const key in types) {
    const keyType = types[key];
    if (typeof obj[key] === keyType) continue;

    invalidKeys.push(key);
  }

  return invalidKeys;
}

const invalidPropertyTypeErrors =
  <T extends object, R extends Record<keyof T, string>>(name: string, obj: T, types: R) =>
    invalidPropertyTypes(obj, types)
      .map(key => [ `${name}.${key}`, types[key], obj[key] ])
      .map(([key, expected, actual]) => `Expected ${key} to be of type ${expected}, is ${actual}`);

function validateUser (user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  }

  const requiredKeys = ['name', 'email', 'age', 'address', 'phone'];
  const missingKeys = missingObjectProperties(user, requiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `User is missing one or more required keys: ${joined}`;
  }


  // notice that this is DOUBLE spaced from above -- this visually aids logical blocking
  const propertyTypes = {
    name: 'string',
    email: 'string',
    age: 'number',
    address: 'string',
    phone: 'string',
  };

  const invalidTypes = invalidPropertyTypes(user, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('user', user, propertyTypes);
    return `User has one or more invalid property types.\n${messages.join('\n')}`;
  }


  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
    return 'User must have a valid email property';
  }

  if (user.age < 18 || user.age > 99) {
    return 'User must have a valid age property';
  }

  if (!user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
    return 'User must have a valid address property';
  }

  if (!/^\d{3}-\d{3}-\d{4}$/.test(user.phone)) {
    return 'User must have a valid phone property';
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Whoa, that's a LOT more code than when we started! Yes, but that's a good thing. We have more reusable code, and everything to do with validating a user can be moved into other files or folders to make for cleaner architecture. Let's move onto the final missing piece: checking that the value is within a set range. Let's start with creating a few helper functions that we can use later. This is creating a set of helper functions and constants where applicable so we are reducing the footprint of a single idea as much as possible. We are checking a single primitive, and returning a boolean for whether it is VALID. A simple rule here is to assert truths, instead of "not truths".

Break Out Your Helpers and Constants

// emailReg is a constant now so we can update it when we need to, if we need to
const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validEmail = (email: string) => emailReg.test(email);

const phoneReg = /^\d{3}-\d{3}-\d{4}$/;
const validPhoneNumber = (num: string) => phoneReg.test(num);

// Valid age ranges are between 18 and 120
const validAge = (age: number) => age > 17 && age < 121;
Enter fullscreen mode Exit fullscreen mode

Now we're going to create a complex function that allows you to pass in an object and a validator object. This validator object is a Record where each key is a key of the object you're validating, and the value is either a single function to validate the value at the property where the key matches on the object, or an array of validators.

// Start by defining what a ValidatorFn is so we can work with it
type ValidatorFn = <T>(arg: T) => boolean;

// This is a way to make it type-safe object where the keys are either a single function,
// or an array of functions that receive the _value_ of an object at that key
type ValidatorRecord = {
  // For every key in T, have an OPTION for either a validator for that type,
  // or an array of validators for that ty0pe
  [key in keyof T]?: ValidatorFn<T[key]> | Array<ValidatorFn<T[key]>>;
}

// getArrayFromMixed allows you to retrieve an array for any type where it T, or an array of T
const getArrayFromMixed =
  <T>(arg: T | T[]) => Array.isArray(arg) ? arg : [arg];

function invalidObjectProperties<T extends object, R extends ValidatorRecord<T>>(
  obj: T,
  validators: R
): Array<keyof T> {
  const invalids: Array<keyof T> = [];

  for (const key in validators) {
    const tests = getArrayFromMixed(validators[key] ?? []);
    if (tests.length < 1) continue;

    // it is valid if EVERY validator passes
    const valid = tests.every(validate => validate(obj[key]));
    if (!valid) continue;

    invalids.push(key)
  }

  return invalids;
}
Enter fullscreen mode Exit fullscreen mode

There's a lot going on in that function, but you'll notice the exact same logical block again for this particular problem. You have a thing. You want to get some kind of data from it, and you want to return it to the caller. This opens up one last set of validator functions we need.

Generate Your Helpers

We need a way to check our strings to ensure that they are a minimum and/or maximum length.

const minLength = (obj: string | any[], len: number) => obj.length >= len;
const maxLength = (obj: string | any[], len: number) => obj.length <= len;
Enter fullscreen mode Exit fullscreen mode

These are great, but there's a problem. Our validator doesn't make it possible for us to pass in a len argument. This means that unless we are calling it directly, outside of the function we just created, we can't use it. Doesn't that defeat the purpose of that complicated function we just created? Actually no! Introduce the concept of Currying.

const minLength = (len: number) => (obj: string | any[]) => obj.length >= len;
const maxLength = (len: number) => (obj: string | any[]) => obj.length <= len;
Enter fullscreen mode Exit fullscreen mode

This is weird function for those who are not familiar. What's happening here? When we're validating a property with our new type, we can only accept 1 argument that is of the same type. So instead of having a single function, we want to "generate" a function that'll have the length we actually care about. A brief example would look like this:

const minLength64 = minLength(64);
const testString = 'My Test String';
console.log(minLength64(testString)); // false
Enter fullscreen mode Exit fullscreen mode

For those unfamiliar, that's wack. We just called a variable? Actually no. The variable we created is called minLength64, yes, but that variable is a function. So our minLength function actually creates new functions for us to use whenever we need, and it takes only 1 argument. This means we can use it as a validator under customized circumstances, and we can now use it in our complicated validator function!

Check Your Direction

Now we have a function that could potentially render all the previous work on keys and types completely useless. We have a function that can just validate a property, why don't we just use invalidObjectProperties for everything? Truth is, I think you should, but it would introduce far too many different functions and types for the scope of this article. However, this function will allow you to group your errors together based on what's relevant for the developer on the other end if you write it properly.

  1. We check for completeness of the object, and can put that in a single group of messages
  2. We check for type correctness of the incoming object
  3. We check for value correctness of the incoming object

We've made a LOT of changes from the original code, and we have created a substantial amount of different kinds of code. You should take this time to clean your code up, and separate into other files.

After a quick clean up, the latest code we have looks like this.

//
// Section: Types
//

type ValidatorFn = <T>(arg: T) => boolean;

// This is a way to make it type-safe object where the keys are either a single function,
// or an array of functions that receive the _value_ of an object at that key
type ValidatorRecord = {
  // For every key in T, have an OPTION for either a validator for that type,
  // or an array of validators for that ty0pe
  [key in keyof T]?: ValidatorFn<T[key]> | Array<ValidatorFn<T[key]>>;
}


//
// Section: Constants
//

// emailReg is a constant now so we can update it when we need to, if we need to
const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneReg = /^\d{3}-\d{3}-\d{4}$/;


//
// Section: Validator Helper Functions
//

const validEmail = (email: string) => emailReg.test(email);

const validPhoneNumber = (num: string) => phoneReg.test(num);

// Valid age ranges are between 18 and 120
const validAge = (age: number) => age > 17 && age < 121;

// These next two functions are indented like this to remind the consumer that this is a
// function returning a function
const minLength =
  (len: number) =>
    (obj: string | any[]) => obj.length >= len;

const maxLength =
  (len: number) =>
    (obj: string | any[]) => obj.length <= len;

//
// Section: Helper Functions
//

// getArrayFromMixed allows you to retrieve an array for any type where it T, or an array of T
const getArrayFromMixed =
  <T>(arg: T | T[]) => Array.isArray(arg) ? arg : [arg];

//
// Section: Validation Functions
//

// missingObjectProperties will return an array of all the properties that are missing from the
// object provided
function missingObjectProperties<T extends object>(obj: T, keys: Array<keyof T>): Array<keyof T> {
  const missing: Array<keyof T> = [];

  for (const key of properties) {
    // a one-liner "early return"
    if (obj.hasOwnProperty(key)) continue;

    missing.push(key);
  }

  return missing;
}

// invalidPropertyTypes takes an object of any type, and an object of key -> type. This is a
// generic function, and seems overly complex, but there's a reason for this.
//
// T extends Object -- the obj MUST be an object of some kind so we can check its properties
//
// R extends Record<keyof T, string> -- This is an object where the only valid keys are also
// keys that you can find in whatever T is. This is for your benefit as the developer, so you
// do not accidentally put keys that are invalid. It is NOT Record<keyof T, string> in the
// situation that you have optional keys.
function invalidPropertyTypes<T extends object, R extends Record<keyof T, string>>(
  obj: T
  types: R,
): Array<keyof T> {
  const invalidKeys: Array<keyof T> = [];

  // this is not using Object.entries because there are situations in which keys are broken
  // by the Object.entries function
  for (const key in types) {
    const keyType = types[key];
    if (typeof obj[key] === keyType) continue;

    invalidKeys.push(key);
  }

  return invalidKeys;
}

const invalidPropertyTypeErrors =
  <T extends object, R extends Record<keyof T, string>>(name: string, obj: T, types: R) =>
    invalidPropertyTypes(obj, types)
      .map(key => [ `${name}.${key}`, types[key], obj[key] ])
      .map(([key, expected, actual]) => `Expected ${key} to be of type ${expected}, is ${actual}`);


function invalidObjectProperties<T extends object, R extends ValidatorRecord<T>>(
  obj: T,
  validators: R
): Array<keyof T> {
  const invalids: Array<keyof T> = [];

  for (const key in validators) {
    const tests = getArrayFromMixed(validators[key] ?? []);
    if (tests.length < 1) continue;

    // it is valid if EVERY validator passes
    const valid = tests.every(validate => validate(obj[key]));
    if (!valid) continue;

    invalids.push(key)
  }

  return invalids;
}

function validateUser(user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  }

  const requiredKeys = ['name', 'email', 'age', 'address', 'phone'];
  const missingKeys = missingObjectProperties(user, requiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `User is missing one or more required keys: ${joined}`;
  }


  // notice that this is DOUBLE spaced from above -- this visually aids logical blocking
  const propertyTypes = {
    name:    'string',
    email:   'string',
    age:     'number',
    address: 'string',
    phone:   'string',
  };

  const invalidTypes = invalidPropertyTypes(user, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('user', user, propertyTypes);
    return `User has one or more invalid property types.\n${messages.join('\n')}`;
  }

  // address is intentionally excluded
  const validators = {
    name:  [ minLength(1), maxLength(128) ],
    email: [ validEmail, minLength(5) ],
    age:   validAge,
    phone: validPhoneNumber,
  };

  const invalidValues = invalidObjectProperties(user, validators);
  if (invalidValues.length > 0) {
    const messages = invalidValues.map(a => `User.${a} failed validation`);
    return `One or more properties of user are invalid.\n${messages.join('\n')}`;
  }

  if (!user.address.hasOwnProperty('street') || typeof user.address.street !== 'string' || !user.address.hasOwnProperty('city') || typeof user.address.city !== 'string' || !user.address.hasOwnProperty('state') || typeof user.address.state !== 'string' || !user.address.hasOwnProperty('zip') || typeof user.address.zip !== 'string' || !/^\d{5}$/.test(user.address.zip)) {
    return 'User must have a valid address property';
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

This means that all the code we've created so far gives us options to improve the developer experience in the future.

Reusing a Whole Pattern

Let's look into the sneaky bit of code we haven't addressed at all yet. We're validating an entire object in a single if, and that's bad bad bad. Let's reuse the pattern we just used for validateUser to create our own validateAddress function. It's going to look remarkably familiar.

function validateAddress(address: any): string | null {
  if (address == null)  {
    return 'Address is undefined or null';
  }

  if (typeof address !== 'object') {
    return 'Address must be an object';
  }

  const requiredKeys = ['street', 'city', 'state', 'zip'];
  const missingKeys = missingObjectProperties(address, requiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `Address is missing one or more required keys: ${joined}`;
  }


  const propertyTypes = {
    street: 'string',
    city:   'string',
    state:  'string',
    zip:    'string',
  };

  const invalidTypes = invalidPropertyTypes(address, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('Address', address, propertyTypes);
    return `Address has one or more invalid property types.\n${messages.join('\n')}`;
  }

  const validators = {
    street: [minLength(5), maxLength(128)],
    city:   [minLength(5), maxLength(128)],
    state:  [minLength(5), maxLength(128)],
    zip:    validZipCode,
  }

  const invalidValues = invalidObjectProperties(address, validators);
  if (invalidValues.length > 0) {
    const messages = invalidValues.map(a => `Address.${a} failed validation`);
    return `One or more properties of address are invalid.\n${messages.join('\n')}`;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Wow that's almost an exact duplicate of the user. This is because the "chunks" within each of those functions are the logical blocks I've been hinting to throughout all of this. Each of these blocks are logical, consistent, and express a single idea. We have a lot more code than when we started, but this code is all usable, testable, and can be separated into different files. The best part is that these blocks allow your eyes to flow through the function without stumbling, and when you're interested in expanding or changing a single piece of functionality, that's now an easy task. That nasty, unreasonably long if statement is now a single logical block when used:

  const addressError = validateAddress(user.address);
  if (addressError) {
    return addressError;
  }
Enter fullscreen mode Exit fullscreen mode

Pull out your constants!!

This leaves us with the last thing I want to talk about. You'll see a lot of constants that just do not need to exist inside of the functions. Let's pull those out.

// User Constants

const requiredUserKeys = ['name', 'email', 'age', 'address', 'phone'];
const userPropertyTypes =  {
  name:    'string',
  email:   'string',
  age:     'number',
  address: 'string',
  phone:   'string',
};

const userPropertyValidators = {
  name:    [ minLength(1), maxLength(128) ],
  email:   [ validEmail, minLength(5) ],
  age:     validAge,
  phone:   validPhoneNumber,
}


// Address Constants

const addressRequiredKeys = ['street', 'city', 'state', 'zip'];
const addressPropertyTypes = {
  street: 'string',
  city:   'string',
  state:  'string',
  zip:    'string',
};

const addressPropertyValidators = {
  street: [minLength(5), maxLength(128)],
  city:   [minLength(5), maxLength(128)],
  state:  [minLength(5), maxLength(128)],
  zip:    validZipCode,
}
Enter fullscreen mode Exit fullscreen mode

We pull out those constants because there is nothing being consumed from within the function. Since these are just "values" that are the same every time the function executes, we can pull those out into constants that we don't have to worry about anymore. They don't need to be in our way. That means our functions look like this now:

function validateUser(user: any): string | null {
  if (user == null) {
    return 'User is undefined or null';
  }

  if (typeof user !== 'object') {
    return 'User must be an object';
  }


  const missingKeys = missingObjectProperties(user, requiredUserKeys);
  if (missingKeys.length > 0) {
    const joined = requiredUserKeys.join(', ');
    return `User is missing one or more required keys: ${joined}`;
  }


  const invalidTypes = invalidPropertyTypes(user, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('user', user, propertyTypes);
    return `User has one or more invalid property types.\n${messages.join('\n')}`;
  }


  const invalidValues = invalidObjectProperties(user, userPropertyValidators);
  if (invalidValues.length > 0) {
    const messages = invalidValues.map(a => `User.${a} failed validation`);
    return `One or more properties of user are invalid.\n${messages.join('\n')}`;
  }


  const addressError = validateAddress(user.address);
  if (addressError) {
    return addressError;
  }

  return null;
};


function validateAddress(address: any): string | null {
  if (address == null)  {
    return 'Address is undefined or null';
  }

  if (typeof address !== 'object') {
    return 'Address must be an object';
  }

  const missingKeys = missingObjectProperties(address, addressRequiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `Address is missing one or more required keys: ${joined}`;
  }



  const invalidTypes = invalidPropertyTypes(address, addressPropertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors('Address', address, addressPropertyTypes);
    return `Address has one or more invalid property types.\n${messages.join('\n')}`;
  }


  const invalidValues = invalidObjectProperties(address, addressPropertyValidators);
  if (invalidValues.length > 0) {
    const messages = invalidValues.map(a => `Address.${a} failed validation`);
    return `One or more properties of address are invalid.\n${messages.join('\n')}`;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Rinse and Repeat

Now we've blocked out our code in a specific way. I think we can go one step further with all of this. We're doing the exact same thing over and over again, but now it's just nicer to look at. We're doing the same 4 things every time:

  1. Ensure it is defined
  2. Ensure the required properties are present
  3. Ensure that all the types of those properties are the same
  4. Ensure that all the values are within a set of ranges

What would that look like if we made it more generalized?

type ValidatorFn = <T>(arg: T) => boolean;

// This is a way to make it type-safe object where the keys are either a single function,
// or an array of functions that receive the _value_ of an object at that key
type ValidatorRecord = {
  // For every key in T, have an OPTION for either a validator for that type,
  // or an array of validators for that ty0pe
  [key in keyof T]?: ValidatorFn<T[key]> | Array<ValidatorFn<T[key]>>;
}

interface IValidateTypeOpts<T extends object> {
  name:          string;
  requiredKeys:  Array<keyof T>;
  validators:    ValidatorRecord<T>;
  propertyTypes: Record<keyof T, string>
}

function validateType<T extends object>(obj: T, opts: IValidateTypeOpts<T>) string | null {
  // destructure because we will be using these
  const { name, requiredKeys, validators, propertyTypes } = opts;

  if (obj == null) {
    return `${name} is null or undefined`;
  }

  if (typeof obj !== 'object') {
    return `${name} is not an object`;
  }


  const missingKeys = missingObjectProperties(obj, requiredKeys);
  if (missingKeys.length > 0) {
    const joined = requiredKeys.join(', ');
    return `${name} is missing one or more required keys: ${joined}`;
  }


  const invalidTypes = invalidPropertyTypes(obj, propertyTypes);
  if (invalidTypes.length > 0) {
    const messages = invalidPropertyTypeErrors(name, obj, propertyTypes);
    return `${name} has one or more invalid property types.\n${messages.join('\n')}`;
  }


  const invalidValues = invalidObjectProperties(obj, validators);
  if (invalidValues.length > 0) {
    const messages = invalidValues.map(a => `${name}.${a} failed validation`);
    return `One or more properties of ${name} are invalid.\n${messages.join('\n')}`;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

You remember how we broke out all those constants that didn't need to exist? Well, now we can reuse them!

const userValidatorOpts = {
  name: 'User'
  requiredKeys: ['name', 'email', 'age', 'address', 'phone'],
  propertyTypes: {
    name:    'string',
    email:   'string',
    age:     'number',
    address: 'string',
    phone:   'string',
  },
  validators: {
    name:    [ minLength(1), maxLength(128) ],
    email:   [ validEmail, minLength(5) ],
    age:     validAge,
    phone:   validPhoneNumber,
  },
};

// Address Constants
const addressValidatorOpts: IValidateTypeOpts<any> = {
  name: 'Address',
  requiredKeys: ['street', 'city', 'state', 'zip'],
  propertyTypes: {
    street: 'string',
    city:   'string',
    state:  'string',
    zip:    'string',
  },
  validators: {
    street: [minLength(5), maxLength(128)],
    city:   [minLength(5), maxLength(128)],
    state:  [minLength(5), maxLength(128)],
    zip:    validZipCode,
  },
};

// usage
validateType(userObj, userValidatorOpts);
validateType(addressObj, addressValidatorOpts);
Enter fullscreen mode Exit fullscreen mode

However, this makes things more complex because you have to remember where your predefined validator opts are. Let's bring back our old friend: Mr. Curry.

type TypeValidator<T> = (arg: T) => string | null;

function validateType<T extends object>(opts: IValidateTypeOpts<T>): TypeValidator<T> {
  // destructure because we will be using these
  const { name, requiredKeys, validators, propertyTypes } = opts;

  return function(obj: T) {
    if (obj == null) {
      return `${name} is null or undefined`;
    }

    if (typeof obj !== 'object') {
      return `${name} is not an object`;
    }


    const missingKeys = missingObjectProperties(obj, requiredKeys);
    if (missingKeys.length > 0) {
      const joined = requiredKeys.join(', ');
      return `${name} is missing one or more required keys: ${joined}`;
    }


    const invalidTypes = invalidPropertyTypes(obj, propertyTypes);
    if (invalidTypes.length > 0) {
      const messages = invalidPropertyTypeErrors(name, obj, propertyTypes);
      return `${name} has one or more invalid property types.\n${messages.join('\n')}`;
    }


    const invalidValues = invalidObjectProperties(obj, validators);
    if (invalidValues.length > 0) {
      const messages = invalidValues.map(a => `${name}.${a} failed validation`);
      return `One or more properties of ${name} are invalid.\n${messages.join('\n')}`;
    }

    return null;
  };
}

// Create your new validator functions
const validateUser = validateType(userValidatorOpts);
const validateAddress = validateType(addressValidatorOpts);

// then use the new validators
validateUser(userObj);
validateAddress(addressObj);
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is what logical blocking is. Logical Blocking is the act of isolating one specific idea or intent visually, and reusing a specific pattern in appropriate fashions to accomplish your tasks. There are significantly more kinds of logical blocks than I can possibly discuss here, but I challenge you to think in terms of logical blocks from now on. I think you'll find that your code becomes much easier to maintain, document, test, and use. It also means that you can build up inertia with your codebase, instead of losing it to having to write the same code over and over again.

One of the biggest issues with projects is the risk of being overly bloated because everyone wants to reinvent the wheel. But having to write code that accomplishes the same task over and over again is a massive pain. We just took our singular, one-off validateUser function with zero reusability, and turned it into an entire set of functionality that we can reuse as needed. Even better, we don't have to test every single validator from now one; we are able to generate a validator that is already tested and have greater confidence that it will work the same. All bugs that exist with validation can now (likely) be isolated to one place instead of n places, where n represents every type you have to validate.

I hope you enjoyed the read!


This is my first article. If you see areas that I can improve, please point it out for me. I would love to improve this based on feedback!

💖 💪 🙅 🚩
rachaeldawn
Rachael Dawn

Posted on April 27, 2023

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

Sign up to receive the latest update from our blog.

Related