DEV Community

loading...

Type-Safe TypeScript

Randall
Updated on ・6 min read

Have you ever wished that TypeScript were a bit more, you know, type-safe?

Have you ever had a string in your TS code only to find out when you run it that, surprise! It's undefined?

I was a relatively late adopter of TypeScript and those were some of my first impressions of it. It seemed like TypeScript was failing to live up to its potential regarding type safety.

Fortunately, with some non-default configuration and some discipline, it is possible to get a substantial degree of both compile-time and run-time type safety from TypeScript. This article discusses some techniques I have found helpful for writing safer TS, and if you have any of your own, let us know in the comments!

Configuration

When you run tsc --init and generate a default tsconfig.json file, it contains a lot of optional strict settings that are commented out by default:

{
  // "noImplicitAny": true,                       /* Raise error on expressions and declarations with an implied 'any' type. */
  // "strictNullChecks": true,                    /* Enable strict null checks. */
  // "strictFunctionTypes": true,                 /* Enable strict checking of function types. */
  // "strictBindCallApply": true,                 /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
  // "strictPropertyInitialization": true,        /* Enable strict checking of property initialization in classes. */
  // "noImplicitThis": true,                      /* Raise error on 'this' expressions with an implied 'any' type. */
  // "alwaysStrict": true,                        /* Parse in strict mode and emit "use strict" for each source file. */

  /* Additional Checks */
  // "noUnusedLocals": true,                      /* Report errors on unused locals. */
  // "noUnusedParameters": true,                  /* Report errors on unused parameters. */
  // "noImplicitReturns": true,                   /* Report error when not all code paths in function return a value. */
  // "noFallthroughCasesInSwitch": true,          /* Report errors for fallthrough cases in switch statement. */
  // "noUncheckedIndexedAccess": true,            /* Include 'undefined' in index signature results */
  // "noImplicitOverride": true,                  /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
  // "noPropertyAccessFromIndexSignature": true,  /* Require undeclared properties from index signatures to use element accesses. */
}
Enter fullscreen mode Exit fullscreen mode

I turn all of them on. Every last one of them. They all help me write better, safer code.

Remember, the power of something like TypeScript is not what it allows us to do, but what it forbids us from doing! Let's take that to the max!

I find that the noImplicitAny option is especially important. Without it, it is easy to end up using any all over the place without intending to.

Unknown is your friend, Any is your enemy

Let's make a small function that reads a file from the disk. We expect the file to be a JSON file containing an object with a name property. We want to get and return the name property. Here is the quick and dirty way:

async function getName(filePath: string): Promise<string> {
  const fileContent = await fs.promises.readFile(filePath, 'utf-8');
  const fileObj = JSON.parse(fileContent);

  return fileObj.name;
}
Enter fullscreen mode Exit fullscreen mode

If the file doesn't exist, an error will be thrown. If the file is not valid JSON, an error will be thrown. So that is great. But if the content of the file simply does not contain a name property, no error will be thrown, and this code will just return undefined, despite claiming to return string!

That is because JSON.parse() returns a value of type any, so TypeScript abandons all type checking on it and assumes we know what it is and what properties it has.

In a major project, it can sometimes be difficult to track down the source of unexpected data types leaking into your code from places like this. So we should prefer to throw an error right here at the source if the file content does not match our expectations.

That is where unknown helps us. If we cast fileObj to unknown, TypeScript will play hardball with us until we prove that fileObj has a name property (or we cast it again, but don't do that! That is where discipline comes into play!).

Here is a type-safe version of this function:

// A type guard function to verify that an object has a certain property
function assertHasProperty<TKeyType extends PropertyKey>(data: object, prop: TKeyType)
  : asserts data is Record<TKeyType, unknown> {
  if (!(prop in data)) {
      throw new Error(`Expected object to have property: ${prop}`);
  }
}

async function getName(filePath: string): Promise<string> {
  const fileContent = await fs.promises.readFile(filePath, 'utf-8');

  // Parse the file content and cast to `unknown`
  const fileObj: unknown = JSON.parse(fileContent);

  // Narrow fileObj to `object` type
  if (typeof fileObj !== 'object' || fileObj === null) {
    throw new Error('The file does not contain an object.');
  }

  // Narrow fileObj to `Record<"name", unknown>`
  assertHasProperty(fileObj, 'name');

  // Narrow fileObj to `Record<"name", string>`
  if (typeof fileObj.name !== 'string') {
    throw new Error('Name property is not a string');
  }

  return fileObj.name;
}
Enter fullscreen mode Exit fullscreen mode

Yes, it is more code, quite a lot more actually (though you can reuse the assertHasProperty function). But now, through the power of unknown and type narrowing, we know for darn sure that at runtime this function either returns a string or throws an error. TypeScript will produce a compile-time error if our code does not have that logical outcome.

Plus, this code handles and clearly reports three separate error conditions that the any code does nothing about. Using unknown and type narrowing forced us to come face-to-face with and handle these error conditions.

As is your enemy

In case it was not crystal clear above, as is an enemy too, just like any.

as allows us to cast any type to any other type without proving in our code that doing so is valid. If we cannot prove that a type conversion is valid, maybe it is not!

any and as do have their places, but the less we use them, the more type-safe our code will be.

Type-safe Array Filtering

You have an array that might have some falsey values, and you want to filter them out. I find myself doing this all the time. Unfortunately, TypeScript is not clever enough to narrow an array type via a call to filter() without an explicit type guard.

Here is an example. We create an array of type (number | null)[], filter out the nulls, and try to square all the numbers:

const arr = [null, 1, null, 2, null, 3]; // type `(number | null)[]`
const filteredArr = arr.filter(e => e !== null); // still type `(number | null)[]`!

// TS error! `Argument of type 'number | null' is not assignable to parameter of type 'number'.
const squaredArr = filteredArr.map(e => Math.pow(e, 2));
Enter fullscreen mode Exit fullscreen mode

We filtered out the nulls, but TS doesn't realize it, and will not allow us to Math.pow() the array elements because it still thinks they might be null.

It is common to just cast with as in this case, but we can use a type guard to be more type-safe:

export function isNotNull<TValueType>(value: TValueType | null): value is TValueType {
  return value !== null;
}

const arr = [null, 1, null, 2, null, 3]; // type `(number | null)[]`
const filteredArr = arr.filter(isNotNull); // type narrowed to number[]!
const squaredArr = filteredArr.map(e => Math.pow(e, 2));
Enter fullscreen mode Exit fullscreen mode

No more error, we conquered it in a type-safe way, without casting.

Then again, it is possible that you could have messed up and written the isNotNull function incorrectly. For example, if you had written return value !== undefined; instead of return value !== null;, TS will compile it, but the type narrowing will then be incorrect.

That is why I like to use the ts-is-present package in my projects. It contains type guards exactly for this use case, so I do not have to write them myself over and over again at three o'clock in the morning.

Class Validator

Here is another great NPM package to add to your arsenal: class-validator

It allows you to easily validate class properties at runtime.

Here is a quick example:

import { IsEmail, IsString, Length, validateSync } from 'class-validator';
import assert from 'assert';

class User {
  @IsString()
  @Length(3, 50)
  username!: string;

  @IsEmail()
  emailAddress!: string;
}

const user = new User();
user.username = 'Herbert';
user.emailAddress = 'turnipfarmer7@hotmail.com';

const validationErrors = validateSync(user);
assert.strictEqual(validationErrors.length, 0, 'Invalid User');
Enter fullscreen mode Exit fullscreen mode

It does require you to be diligent about calling the validation functions and handling validation errors, but if used carefully this is a powerful tool for runtime type checking and other validation. I have found it especially great for validating request bodies and records queried from the database. Consider using class-transformer with it to easily transform POJO's into class instances for validation.

Conclusion

TypeScript is a revolutionary tool for JS developers looking to improve their code quality and development experience.

But it still leaves you with plenty of opportunities to shoot yourself in the foot. It is just JavaScript under the hood, after all.

Using TypeScript to its full potential requires understanding its limitations, knowing the tools to work around them, and most importantly, having the discipline and motivation to use it carefully.

Do you have any tips for using TypeScript more safely? Let us know in the comments!

Discussion (0)