DEV Community

Cover image for Maintaining TypeScript Superpowers When Types Are Out of Reach
Rida F'kih
Rida F'kih

Posted on • Edited on

Maintaining TypeScript Superpowers When Types Are Out of Reach

It’s all too common. You’re coding away in TypeScript, utilizing an external library, and you’re digging around the code for that type you just can’t find and… bam! The type you’ve been searching for is not exported!

You could either re-create the types locally, fork the repository and export the types, or work with what you’ve got! Today, I’m going to show you how to do the latter.

Inferred Return Types

Let's say we're utilizing a function from an external library which returns an object that lacks explicitly defined type information as follows.

const divideNumber = (dividend: number, divisor: number) => {
  const quotient = dividend / divisor;
  return { dividend, divisor, quotient };
};

/**
 * This function serves as an example of a function which returns
 * an inferred type, meaning the return type is not explicitly
 * defined by the developer.
 */
Enter fullscreen mode Exit fullscreen mode

When using this function, TypeScript will automatically infer the type as follows.

{
     dividend: number;
     divisor: number;
     quotient: number;
}

/**
 * This is what the inferred return type of the previous function 
 * looks like.
 */
Enter fullscreen mode Exit fullscreen mode

This is fine for basic usage, but if we want to do something with the type, it may become difficult.

If you're unfamiliar with the ReturnType, your first instinct might be to recreate the type yourself as follows, but doing so means we’ll be deviating from a single source of truth.

interface DivisionObject {
  dividend: number;
  divisor: number;
  quotient: number;
}

/**
 * This is a bad example of a solution to define a type for the
 * return type of the previous function.
 */
Enter fullscreen mode Exit fullscreen mode

Thanks to ReturnType, which takes a generic—that being a function type signature—returns its return type, even if its not explicitly named.

We can either use it directly, or assign it to our own named type.

import { divideNumber } from "@fake-library/math";

type DivisionObject = ReturnType<typeof divideNumber>;

/**
 * Here we are using the ReturnType generic to define a type as
 * the inferred return type of the divideNumber function.
 */
Enter fullscreen mode Exit fullscreen mode

Now we are able to use this as a property in another local function.

const verifyDivision = ({ dividend, divisor, quotient }: DivisionObject) => {
  return divisor * dividend === quotient;
};

/**
 * Here we're taking the type we defined and defining it as the
 * first property of the verifyDivision function.
 */
Enter fullscreen mode Exit fullscreen mode

🎉 Awesome! This is so much better!

Nested, Unnamed Types

Sometimes we do have an exported type, but we just want one property of that type. Again, if you're unfamiliar with TypeScript your first instinct might be to simply redeclare the type. However, we can actually access that by simply using our handy square brackets.

Let’s say we have the following interface.

interface HumanBeing {
  firstName: string;
  lastName: string;
  locale: {
    country: string;
    language: "english" | "french" | "spanish";
    timeZone: string;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now let's say we want to create a function that checks if a human being’s language is English.

type HumanBeingLanguage = HumanBeing["locale"]["language"];

const isLanguageEnglish = (language: HumanBeingLanguage) => {
  return language === "english";
};
Enter fullscreen mode Exit fullscreen mode

✨ Easy peasy, right?

Variable Unnamed Object Type Signatures

This one is a little more obscure, but something I ran into while creating a custom Notion renderer for the latest version of my personal portfolio. If we have an array where we have multiple possible type signatures of objects, and the possible object types in question are not named, things can get yucky very quickly.

type CustomHTMLElement =
  | {
      tag: "img";
      attributes: {
        src: string;
        alt: string;
      };
    }
  | {
      tag: "video";
      attributes: {
        src: string;
        type: string;
      };
    };

/**
 * As you can see, the above type can have multiple different
 * object type signatures, despite being under the same namespace.
 */
Enter fullscreen mode Exit fullscreen mode

As you can see, the attributes are inconsistent.

Let’s define an array of objects which utilizes our CustomHTMLElement type.

const customElements: CustomHTMLElement[] = [
  {
    tag: "img",
    attributes: {
      src: "https://example.com/image.png",
      alt: "Example image",
    },
  },
  {
    tag: "video",
    attributes: {
      src: "https://example.com/video.mp4",
      type: "video/mp4",
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Let's say we want to get a list of all the img elements, our first instinct might be to use the raw Array#filter method.

const getAllImgElements = () => {
  return customElements.filter(({ tag }) => tag === "img");
};

getAllImgElements().forEach((element) => {
  element.attributes.alt; /** Property 'alt' does not exist on type
                            '{ src: string; alt: string; }
                           | { src: string; type: string; }'.
                                                    */
});

/**
 * Unfortunately, using filter with this type, we get an 
 * error since TypeScript cannot make an inference from this
 * method.
 */
Enter fullscreen mode Exit fullscreen mode

As you can see, the problem with this is Array#filter does not immediately respect type inferences. TypeScript still has no idea that any of the other object types have been eliminated as a possibility.

In this case, the inferred type of the return type of the getAllImgElements function is () => CustomHTMLElement[], so we still have the exact same original degree of ambiguity.

Thankfully, we can leverage the Extract generic type available in TypeScript 2.8+ combined with a TypeScript predicate to tell TypeScript what to expect after we’ve filtered.

type CustomHTMLElementTag<T> = Extract<CustomHTMLElement, { tag: T }>;

const getAllImgElements = () => {
  return customElements.filter(
    (element): element is CustomHTMLElementTag<"img"> => element.tag === "img"
  );
};

getAllImgElements().forEach((element) => {
  element.attributes.alt; /** TypeScript is happy! 🎉 */
});

/**
 * Here, we're using a type guard, a type predicate, and the
 * TypeScript Extract generic to tell TypeScript which type
 * is making it out the other end. 
 */
Enter fullscreen mode Exit fullscreen mode

Conclusion

TypeScript is an absolute beast, but because of the nature of its subset language: JavaScript, things can get a little funky sometimes. Luckily, we all love a challenge.

You can read more content like this on my personal blog!

If you liked this post, check me out on Twitter to be kept up to date on future posts! ❤️

Top comments (0)