Skip to content

Instantly share code, notes, and snippets.

@disintegrator
Last active November 3, 2021 02:45
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save disintegrator/77c67cae58f88245418b121895ad6c7f to your computer and use it in GitHub Desktop.
Save disintegrator/77c67cae58f88245418b121895ad6c7f to your computer and use it in GitHub Desktop.
Type holes in TypeScript

Type holes in TypeScript

In TypeScript there are a few ways you can sidestep type inference and the compiler to introduce type holes in your code. Type holes are parts of the code where we’ve lied or hid information from the compiler and can be a source of bugs that are not present in properly typed code. The common ones I see are listed below in order of really bad to bad.

Type assertions

const something = getValue() as any;
const cat = dog as Cat;

In these cases, you are lying to TypeScript so it doesn't complain to you. The only times where this could be permissible is if you are working around an issue coming from third-party libraries with flaky typings. Any time you do this, add a comment explaining why you need the assertions. If someone submits code with forced type assertions, be extra critical of it and investigate safer alternatives.

Side note: Type narrowing with as const

One important distinction from type assertions is type narrowing in TypeScript which, ironically, uses the as const keywords and so appears quite similar to type assertions. In some respect narrowing is a useful type of assertion since we are instructing the compiler to use a stricter type for some value. Here is an example of type narrowing:

type Cat = { species: 'cat', breed: string };
type Dog = { species: 'dog', applications: 'farming' };

type Pet = Cat | Dog

const value = {
    species: 'cat',
    breed: 'norwegian'
}
// typeof value:
// {
//     species: string;
//     breed: string;
// }

const pet: Pet = value
//    ^^^
// Type '{ species: string; breed: string; }' is not assignable to type 'Pet'.
//   Type '{ species: string; breed: string; }' is not assignable to type 'Cat'.
//     Types of property 'species' are incompatible.
//       Type 'string' is not assignable to type '"cat"'.(2322)


const stricterValue = {
    species: 'cat',
    breed: 'norwegian'
} as const;
// typeof stricterValue:
// {
//     readonly species: "cat";
//     readonly breed: "norwegian";
// }


const anotherPet: Pet = stricterValue
// This works fine

Generic functions

Generic functions allow the consumer to hard code the type parameters of any arguments and return values. This can allow us to circumvent TypeScript's type inference and create type holes.

function get<Value, Input = any>(input: Input, key: string): Value;

Lodash is a big culprit here. It's get function, for example, allows you to specify an arbitrary return type and side step the compiler - another type hole.

import { get } from "lodash";

interface BlogPost {
  id: string;
  title: string;
  author?: {
    name: string;
  };
}

const blogPost: BlogPost = {
  id: "1",
  title: "Demo blog post",
  author: { name: "George H." }
};
const authorName = get<number>(blogPost, "author.name");
console.log(authorName);

// The type of authorName is number and TypeScript does not complain about it

This code actually passes TS compiler checks which is not good at all. If you want to write safe code, avoid dynamic accessors and mutators like lodash's get/set and similar useful functions from other libraries. Leverage language features like optional chaining and take a nice walk with your compiler towards safe code.

interface BlogPost {
  id: string;
  title: string;
  author?: {
    name: string;
    address?: {
      city: string;
    };
  };
}

const blogPost: BlogPost = {
  id: "1",
  title: "Demo blog post",
  author: { name: "George H." }
};

const authorName = blogPost.author?.name;
const authorCity = blogPost.author?.address?.city;
console.log(authorName, authorCity);

any

All values of any type can be assigned to any and any can be assigned to any type. This is another common and massive type hole.

const add = (left: number, right: number) => left + right;
const a: any = { hello: "world" };
const b: any = "ok";
add(a, b);

// No complaints from TypeScript

Overall, any reduces strictness in the codebase and re-introduces common bugs found in JavaScript code. The exception to this rule is when dealing with data coming from the outside world. For example, when making an API call to receive some data, the most you can say about its type is that it is any. There is no way you can assert what you'll get from the network at compile time unless you are working with statically typed protocols like gRPC and GraphQL. When your code is given data that is of type any be very defensive when accessing it. To go along with this, it is very useful to create an ambient type alias called declare type EXTERNAL = any. Use this alias to document values that come from third party, untyped libraries and the network,

// lib.d.ts
// create an ambient type alias
type EXTERNAL = any;
// posts.ts
const res = await fetch("/posts/1");
const data = await res.json();
// The only valid things you can about data is that it is `any` or, even better, `unknown`

const cleanAuthorName = (raw: EXTERNAL): string | null => {
  if (!raw || !raw.author) {
    return null;
  }
  const { name } = raw.author;
  return typeof name === "string" ? name : null;
};

const authorName = cleanAuthorName(raw);

The code above is introducing safety around arbitrary data. Since we are relying on our own code to ensure safety, cleanAuthorName should be accompanied with rigorous testing.

User-Defined Type Guards

These are an advanced feature in TypeScript. You can read about them here: Advanced Types.

In this instance, we are asserting the type of something using special functions and asking TypeScript to trust us that we got it right. Notice the flaw in this type guard:

interface BlogPost {
  id: string;
  title: string;
  author?: {
    name: string;
  };
  comments: {
    count: number;
    entries: string[];
  };
}
const isBlogPost = (value: any): value is BlogPost => {
  return value.id != null;
};

const res = await fetch("/posts/1");
const data = await res.json();
// Assume data is { id: "1" };

if (isBlogPost(data)) {
  console.log(data.comments.count);
}

The type guard was not exhaustive in checking the opaque value and when this code runs it will try to access an undefined field (data.comments) and crash.

Type guards can be very flaky because they require trust in developers to be exhaustive with their checks. I personally prefer to use safe field accessors described in the any section (cleanAuthorName above).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment