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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
🎉 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;
};
}
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";
};
✨ 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.
*/
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",
},
},
];
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.
*/
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.
*/
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.
If you liked this post, check me out on Twitter to be kept up to date on future posts! ❤️