How to Write Better TypeScript
How to write better TypeScript
TypeScript: love it or hate it, you can’t deny the fact that it’s spreading like wildfire. In fact, according to the Stack Overflow 2019 developer survey, it was listed as the third most-loved programming language and the fourth most-wanted.
Now whether the survey accurately represents real-world sentiments is up for debate, but the fact remains that TypeScript is here, and it has come to stay. It’s so pervasive now that it has replaced vanilla JavaScript as the language of choice for many packages in the JS ecosystem, with some like Yarn even going as far as rewriting their entire codebase in TypeScript.
I feel one of the reasons for this meteoric rise to success has to be the fact that TypeScript, in essence, is just JavaScript. This makes the entry bar a lot lower for existing JavaScript developers, and the fact that it’s typed may also attract other devs who prefer the features typed languages provide.
This cuts both ways, too, because the ease of picking up TypeScript has led to some cases where the language is not being utilized as effectively as it could be. Many developers still write TypeScript like they’re writing JavaScript, and this brings with it some disadvantages.
We’ll be looking at some real-world code written in TypeScript that could be improved to make better use of the language’s strengths. This is by no means an exhaustive list, and I welcome you to list some you might have noticed in the comments section below.
Some of these examples involve React because I’ve noticed some instances where React code could be improved by simply making use of some TypeScript features, but the principles are by no means limited to React. Let’s dive in.
NOTE: Many code snippets in this article have been taken from real-world projects and anonymized to protect their owners.
Interface neglect
Let’s start with one of the most useful features of TypeScript: interfaces.
In TypeScript, an interface simply specifies the expected shape of a variable. It’s as simple as that. Let’s look at a simple interface to drive the point home.
interface FunctionProps {
foo: string;
bar: number;
}
Now if any variable is defined to implement FunctionProps, it will have to be an object with the keys foo and bar. Any other key addition will make TypeScript fail to compile. Let’s look at what I mean.
const fProps: FunctionProps = {
foo: 'hello',
bar: 42,
}
Now we have an object fProps that implements the FunctionProps interface correctly. If I deviate from the shape specified in the interface by, say, writing fProps.foo = 100 or deleting fProps.bar, TypeScript will complain. fProps’s shape has to match FunctionProps exactly or there will be hell to pay.
fProps.foo = true // foo must be a string
Now that we’ve gotten that out of the way, let’s look at an example. Take this React functional component method:
const renderInputBox = (props, attribute, index) => {
return (
<div key={index} className="form-group">
{renderLabel(attribute)}
<InputBox
name={attribute.key}
value={!!isAssetPropAvailable(props, attribute) && props.inputValue}
onChange={props.handleInputChange}
placeholder={`Enter ${attribute.label}`}
/>
</div>
);
};
While this is completely fine if you were writing JavaScript, it doesn’t take advantage of interfaces. Why is this bad? For one, you don’t get any IntelliSense features that you otherwise would if the method’s arguments were typed.
Also, you could easily pass in a prop of a different expected shape to this method and you would be none the wiser because TypeScript would not complain about it.
This is just vanilla JS, and you might as well eliminate TypeScript from the project altogether if everything was written like this.
How could we improve this? Well, take a look at the arguments themselves, how they’re being used, and what shape is expected of them.
Let’s start with props. Take a look at line 7 and you can see that it’s supposed to be an object with a key called inputValue. On line 8, we see another key being accessed from it called handleInputChange, which, from the context, has to be an event handler for inputs.
We now know what shape props is supposed to have, and we can create an interface for it.
interface PropsShape {
inputValue: string;
handleInputChange: (event: React.FormEvent): void;
}
Moving on to attribute, we can use the same method to create an interface for it. Look at line 6. We’re accessing a key called key from it (hint: it’s an object). On line 9, we’re accessing another key from it called label, and with this information, we can go ahead and create an interface for it.
interface AttributeShape {
key: string;
label: string;
}
We can now rewrite the method to look like this instead:
const renderInputBox = (props:PropsShape, attribute:AttributeShape, index:number) => {
return (
<div key={index} className="form-group">
{renderLabel(attribute)}
<InputBox
name={attribute.key}
value={!!isAssetPropAvailable(props, attribute) && props.inputValue}
onChange={props.handleInputChange}
placeholder={`Enter ${attribute.label}`}
/>
</div>
);
};
Is it more code to write? Yes. But consider the benefits of doing this:
- You get IntelliSense wherever you use this method, and you can instantly see what its arguments are supposed to look like without having to look at it.
- You can never misuse this method because TypeScript will not allow you to pass in arguments with wrong shapes.
- Any change to the method definition — maybe index is now a string — and TypeScript will prevent your code from compiling until you fix all the instances where the method was used.
Why use TypeScript in the first place if you don’t care about these benefits?
Any abuse
The type any is a fantastic way for you to migrate an existing JavaScript project gradually to TypeScript.
Why is this? Well, if you type a variable as any, you’re telling TypeScript to skip type-checking it. You can now assign and reassign different types to this variable, and this allows you to opt-in and out of type checking when necessary.
While there may be other use-cases for using any, such as when you’re working with a third-party API and you don’t know what will be coming back, it is definitely possible to overuse it and, in effect, negate the advantages of TypeScript in the process.
Let’s take a look at a case where it was definitely abused.
export interface BudgetRequiredProps {
categoryDetails?: any[];
state?: any;
onInputChange?: (event) => void;
toggleSubCategory?: (type: any) => any;
displaySubCategory?: () => any[];
}
This interface breaks my heart. There are legitimate use cases for any, but this is not one of them.
For instance, take a look at line 2, where we’re basically specifying an array that can hold content of any type. This is a bomb waiting to explode wherever we’re mapping over categoryDetails, and we don’t account for the fact that it may contain items of different types.
NOTE: If you need to work with an array that contains elements of different types, consider using a Tuple.
Line 3 is even worse. There is no reason why state’s shape should be unknown. This whole interface is basically doing the same thing as vanilla JS with regards to type checking, i.e, absolutely nothing. This is a terrific example of interface misuse.
If you have ever written an interface like this in production code, I forgive you, but please do not let it happen again. Now, I went through the codebase where this example was plucked from to look at the expected shapes of the variables, and this is how it should look:
export interface BudgetRequiredProps {
categoryDetails?: CategoryShape[];
state?: string | null;
onInputChange?: (event: React.FormEvent) => void;
toggleSubCategory?: (type: string) => boolean;
displaySubCategory?: () => CategoryShape[];
}
Much better. You get all the advantages of using TypeScript without changing the interface too much. Now let’s take a look at where using any actually makes sense.
export interface WeatherPageProps {
getCurrentWeatherStatus: (city: string): Promise<any>;
handleUserUpdate: (userContent: any): Promise>any>;
}
Why is this a valid use case for any?
Well, for one, we’re working with an external API. On line 2, we’re specifying a function that makes a fetch request to a weather API, and we don’t know what the response should look like; maybe it’s an endpoint that returns dynamic data based on certain condition.
In that case, specifying the return type as a promise that resolves to any is acceptable.
NOTE: This is not the only approach to working with dynamic data. You could specify all the possible values coming from the endpoint in the interface and then mark the dynamic fields as optional.
On line 3, we’re also working with a function that takes in a prop that is dynamic in content. For instance, say userContent comes from the user, and we don’t know what the user may type. In this case, typing userContent as any is completely acceptable.
Yes, there are valid use-cases for the any type, but please, for the love of TypeScript, avoid it as much as you possibly can without ruining the developer experience.
Index signature misuse
Now, this is a very subtle mistake I see quite a lot in React code where you may need to map over an object and access its properties dynamically. Consider this example:
const obj = {
gasoline: 'flammable',
sauce: 'hot',
isTypeScriptCool: true,
}
Object.keys(obj).forEach(key => console.log(obj[key])) // 'flammable', 'hot', true
The above example will not cause an issue with vanilla JavaScript, but the same is not true in TypeScript.
interface ObjectShape {
gasoline: string;
sauce: string;
isTypeScriptCool: boolean;
}
const obj: ObjectShape = {
gasoline: 'flammable',
sauce: 'hot',
isTypeScriptCool: true,
}
Object.keys(obj).forEach(key => console.log(obj[key])) // you can't just do this
The reason you can’t just do that is because of type indexing.
In TypeScript, you need to specify how an interface should be indexed into by giving it an index signature, i.e, a signature that describes the types we can use to index into the interface, along with the corresponding return types.
A quick refresher: indexing into an object looks like obj[‘sauce’] or obj.gasoline
We didn’t tell TypeScript what index signature ObjectShape should have, so it doesn’t know what to do when you index into an object that implements it as we do on line 13. But how does this concern React?
Well, there are cases where you might need to iterate over a component’s state to grab certain values, like so:
interface ComponentState {
nameError: string;
ageError: string;
numOfFields: number;
}
this.state: ComponentState = {
nameError: 'your name is too awesome',
ageError: 'you seem immortal',
numOfFields: 2,
}
Object.keys(this.state).forEach(err => this.handleError(this.state[err]));
This is a very common operation in React, but you can see how we may run into a problem on line 13. We’re indexing into this.state, but the interface it implements doesn’t have an index signature. Oops.
But that is not even the mistake I’m talking about, and I’ll get to it in a moment. To fix the warning TypeScript throws, you might update the state’s interface like so:
interface ComponentState {
nameError: string;
ageError: string;
numOfFields: number;
[x: string]: any; // index signature added
}
Before we continue, it’s worth noting that, by default, adding an index signature to an interface also means you will be able to add new values that do not exist in the interface to any variable that implements it.
This will successfully get rid of the error, but now you’ve introduced a new side effect.
This is the equivalent of telling TypeScript that when ComponentState is indexed with a string, it should return a value of type any (basically all possible types). This could cause issues if this.handleError was not expecting anything apart from a string or a number.
But more importantly, you can now add a new property with ANY type to whichever variable implements the interface, which, in our case, is this.state. So this becomes valid:
this.state['shouldNotBeHere'] = { bugs: 1, dev: 0 }
Now that is the mistake I am talking about. How do we fix it, though? Well, there are actually two things we need to look out for:
- We want to specify all possible index return types in the object, but no more (no any)
- We don’t want to be able to add new values to an object because of indexing So, in most cases, the proper way to fix our initial issue (indexing into an object without TypeScript complaining) would be to do this:
interface ComponentState {
nameError: string;
ageError: string;
numOfFields: number;
readonly [x: string]: string | number;
}
OK, so here’s what this piece of code is saying:
Hey, TypeScript, I would like to be able to index into this interface with a string, and I should get either a string or a number back. Oh, and please don’t let me add any other thing to any object that implements this interface that I didn’t explicitly specify.
By simply specifying the index signature return values, we’re able to solve our first issue, and by marking it as readonly, we’re able to solve the second issue. Please watch out for this subtle issue when writing TypeScript code.
Conclusion
TypeScript is a wonderful way to write type-safe JavaScript, but you have to do it right. It’s possible to write TypeScript in a way that just causes headaches with no real benefit, but thankfully, that can be easily solved by taking the time to learn the gotchas of the language.
I hope you were able to learn one or two things from this article, and if you have some examples you’d like to share, please add them in the comment section below so that others can benefit.
Goodbye and happy coding!
About the Author
Ovie is a frontend developer with an eye for detail and proven expertise in turning ideas into performant, fully-functional software. Read the original article or more interesting posts on Ovie’s blog.
Open Source Session Replay
OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.
Start enjoying your debugging experience - start using OpenReplay for free.