TypeScript

10 bad TypeScript habits to break this year

TypeScript and JavaScript have steadily evolved over the last years, and some of the habits we built over the last decades have become obsolete. Some might never have been meaningful. Here's a list of 10 habits that we all should break.

If you are interested in more articles and news about web product development and entrepreneurship, please feel free to subscribe to new articles via email, via RSS, or to follow me on Twitter.

Onto the examples! Please note that each "What it should look like" only fixes the issue discussed, even if there are further code smells that should be addressed.

1. Not using strict mode

What it looks like

Using a tsconfig.json without strict mode.

{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs"
}
}

What it should look like

Just enable strict mode:

{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"strict": true
}
}

Why we do it

Introducing stricter rules in an existing codebase takes time.

Why we shouldn't

Stricter rules will make it easier to change code in the future, so the time spent on fixing the code is returned and then some when working on the repository in the future.

2. Defining default values with ||

What it looks like

Falling back with || for optional values:

function createBlogPost (text: string, author: string, date?: Date) {
return {
text: text,
author: author,
date: date || new Date()
}
}

What it should look like

Use the new ?? operator, or, even better, define the fallback right at the parameter level.

function createBlogPost (text: string, author: string, date: Date = new Date())
return {
text: text,
author: author,
date: date
}
}

Why we do it

The ?? operator has just been introduced last year, and when using values in the middle of a long function it might be hard to set them already as parameter defaults.

Why we shouldn't

??, unlike ||, falls back only for null or undefined, not for all falsy values. Also, if your functions are so long that you cannot define defaults at the beginning, then splitting them might be a good idea.

3. Using any as type

What it looks like

Using any for data when you are unsure about the structure.

async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: any = await response.json()
return products
}

What it should look like

In almost every situation where you type something as any, you should type it as unknown instead.

async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}

Why we do it

any is convenient, as it basically disables all type-checks. Often, any is used even in official typings (e. g. response.json() from the example above is typed as Promise<any> by the TypeScript team).

Why we shouldn't

It basically disables all type-checks. Anything that comes in via any will completely forego any type-checks. This leads to hard to catch bugs, as code will fail only when our assumptions about type structure are relevant to the runtime code.

4. val as SomeType

What it looks like

Forcefully telling the compiler about a type that it cannot infer.

async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}

What it should look like

That's what type guards are for.

function isArrayOfProducts (obj: unknown): obj is Product[] {
return Array.isArray(obj) && obj.every(isProduct)
}
function isProduct (obj: unknown): obj is Product {
return obj != null
&& typeof (obj as Product).id === 'string'
}
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
if (!isArrayOfProducts(products)) {
throw new TypeError('Received malformed products API response')
}
return products
}

Why we do it

When converting from JavaScript to TypeScript, the existing codebase often makes assumptions about types that cannot be deduced automatically by the TypeScript compiler. In these cases, throwing in a quick as SomeOtherType can speed up the conversion without having to loosen the settings in tsconfig.

Why we shouldn't

Even if the assertion might be save right now, this might change when someone moves code around. The type guard will ensure that all checks are explicit.

5. as any in tests

What it looks like

Creating incomplete stand-ins when writing tests.

interface User {
id: string
firstName: string
lastName: string
email: string
}
test('createEmailText returns text that greats the user by first name', () => {
const user: User = {
firstName: 'John'
} as any
expect(createEmailText(user)).toContain(user.firstName)
}

What it should look like

If you need to mock data for your tests, move the mocking logic next to the thing you mock and make it reusable.

interface User {
id: string
firstName: string
lastName: string
email: string
}
class MockUser implements User {
id = 'id'
firstName = 'John'
lastName = 'Doe'
email = 'john@doe.com'
}
test('createEmailText returns text that greats the user by first name', () => {
const user = new MockUser()
expect(createEmailText(user)).toContain(user.firstName)
}

Why we do it

When writing tests in a codebase that doesn't have great test coverage yet, there are often complicated big data structures, but only parts of it are needed for the specific functionality under test. Not having to worry about the other properties is easier in the short term.

Why we shouldn't

Foregoing the creation of a mock will bite us, latest when one of the properties changes and we need to change it in all tests instead of one central location. Also, there will be situations where the code under test relies on properties that we did not deem important before, and then all tests for that functionality need to be updated.

6. Optional properties

What it looks like

Marking properties as optional that are sometimes there and sometimes not.

interface Product {
id: string
type: 'digital' | 'physical'
weightInKg?: number
sizeInMb?: number
}

What it should look like

Explicitly model which combinations exist and which don't.

interface Product {
id: string
type: 'digital' | 'physical'
}
interface DigitalProduct extends Product {
type: 'digital'
sizeInMb: number
}
interface PhysicalProduct extends Product {
type: 'physical'
weightInKg: number
}

Why we do it

Marking properties as optional instead of splitting out types is easier and produces less code. It also requires a deeper understanding of the product being built and might limit usage of code if assumptions about the product change.

Why we shouldn't

The big benefit of type systems is that they can replace runtime checks with compile-time checks. With more explicit typing, it is possible to get compile-time checks for bugs that otherwise might have gotten unnoticed, e. g. by making sure that every DigitalProduct has a sizeInMb.

7. One letter generics

What it looks like

Naming a generic with one letter

function head<T> (arr: T[]): T | undefined {
return arr[0]
}

What it should look like

Giving a full descriptive type name.

function head<Element> (arr: Element[]): Element | undefined {
return arr[0]
}

Why we do it

This habit grew I guess because even the official docs use one-letter names. It is also quicker to type and requires less thinking to press T instead of writing a full name.

Why we shouldn't

Generic type variables are variables, like any other. We have abandoned the idea of describing the technicalities of variables in their names when IDEs started to just show us these technicalities. E. g. instead of const strName = 'Daniel' we now only write const name = 'Daniel'. Also, one letter variable names are generally frowned upon because it can be hard to decipher what they mean without looking at their declaration.

8. Non-boolean boolean checks

What it looks like

Checking whether a value is defined by passing the value directly to an if statement.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

What it should look like

Explicitly checking for the condition we care about.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

Why we do it

Writing the check in short looks more succinct and allows us to avoid thinking about what we actually want to check.

Why we shouldn't

Maybe we should think about what we actually want to check. The examples above for example handle the case of countOfNewMessages being 0 differently.

9. The Bang Bang operator

What it looks like

Converting a non-boolean value to boolean.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (!!countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

What it should look like

Explicitly checking for the condition we care about.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

Why we do it

To some, understanding !! is like an initiation ritual to the world of JavaScript. It looks short and succinct, and if you are already used to it, then you know what it is about. It is a shortcut to convert any value to a boolean. Especially if, in a codebase, there is no clear semantic separation between falsy values like null, undefined, and ''.

Why we shouldn't

Like many shortcuts and initiation rituals, using !! obfuscates the true meaning of the code by promoting insider knowledge. This makes the codebase less accessible to new developers, be it new to developing in general, or just new to JavaScript. It's also quite easy to introduce subtle bugs. The problem with countOfNewMessages being 0 from "Non-boolean boolean checks" persists with !!.

10. != null

What it looks like

The little sister of the bang bang operator, != null allows us to check for null and undefined at the same time.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages != null) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

What it should look like

Explicitly checking for the condition we care about.

function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}

Why we do it

If you got here, your codebase and your skills are already in quite good shape. Even most linting rulesets that enforce using !== over != make an exemption for != null. If there is no clear distinction in the codebase between null and undefined, then != null helps to shorten a check for both possibilities.

Why we shouldn't

While null values were quite a hassle in the early days of JavaScript, with TypeScript in strict mode, they can become a valuable member of the language's tool belt. A common pattern I have seen is defining null values as things not being there, and undefined as not things that are unknown, e. g. user.firstName === null might mean that the user literally does not have a first name, while user.firstName === undefined just means that we haven't asked that user yet (and user.firstName === '' would mean the first name literally is '' - you'd be surprised which kind of names actually exist).