At least 7 reasons to avoid @ts-expect-error and @ts-ignore

After a deep dive, I now consider both to be dangerous.

Picture the scene. You've run into a type-level problem in your application, and you just want to suppress the error for now and come back later to resolve it. You know (via tests) that the value works, you just need to please Typescript temporarily so you can move forward...

interface Args {
  selector: string;
  element: HTMLElement
}

function matches({selector, element}: Args) {

}

matches({ selector: 120, element: document.body })
                    ^^^
Type  number  is not assignable to type  string 
  • ✅ Starting with a correct example - this should be a Typescript error. The property selector of Args has the type string, but we're passing a number. Nothing too interesting here, but we'd like to silence that error somehow.

Line-level error silencing @ts-expect-error / @ts-ignore

A common technique is to throw a quick @ts-expect-error down, and then annotate the comment with a link to a ticketing system. The idea is that it's a searchable thing in the codebase and will be worked on when time allows. In the meantime it provides a single place for others to read up on the reasoning.

For the remainder of this post I'll refer to @ts-expect-error and @ts-ignore interchangeably. For what I'm describing they are equal.

function matches({selector, element}: { selector: string; element: HTMLElement }) {

}

// @ts-expect-error - https://example.com/ticket/123
matches({ selector: 120, element: document.body })
  • This silences the error from the subsequent line, and we're linking to a 'cleanup' task that the next developer can view.

On the surface, this looks good, it seems like this feature from Typescript is specifically built for this scenario. After all, libraries change, Typescript upgrades, your app evolves... sometimes we just need to silence an error and move on.

But, is this really the best way to do that?

Exploring the gotchas

So, what can go wrong with a seemingly harmless @ts-expect-error?

The following is nowhere near an exhaustive list, it's just 7 individual examples I could think of, all based around the single topic of calling a function.

BTW - if I was able to find this many, on a single Javascript construct (a function call), just imagine how many actual cases there would be in any modern Typescript codebase 🤯

1️⃣ A 'typo' on the property name is allowed 😭

// @ts-expect-error - https://example.com/ticket/123
matches({ selectar: 120, element: document.body })
  • ❌ OOPS! we made a typo, selectar instead of selector (did you spot it? 👀)
  • Now Typescript will allow this mistake, because of the line-level @ts-expect-error
  • 😭 We only wanted to ignore the type of the selector field, not the field name itself!

2️⃣ Type-checking on other properties is impacted 😭

// @ts-expect-error - https://example.com/ticket/123
matches({ selector: 120, element: 'lol!' })
  • ❌ OOPS! element should have the type HTMLElement, but we passed a string by mistake!
  • 😭 We only wanted to ignore value of the selector field, not the types other properties!

3️⃣ Typos on other property names are allowed 😭

// @ts-expect-error
matches({ selector: 120, elemant: document.body })
  • ❌ OOPS! another typo, this time we spelt elemant where it should be element.
  • Even though the type is correct in this instance, HTMLElement, it can still cause runtime bugs since the name is now wrong!

4️⃣ Absolutely anything will be allowed as a parameter 😭

// @ts-expect-error
matches(Infinity)
  • ❌ OOPS! this is wrong on so many levels - the line-level @ts-expect-error is effectively switching off the type-checker, risking all sorts of runtime bugs.

Localised @ts-expect-error

Hopefully you're sufficiently convinced already about the dangers of @ts-expect-error when applied to entire lines of code.

I only gave a tiny example of a really simple function, with a couple of ways of calling it. With that alone we were already able to identify 4 places where unrelated bugs could creep in.

So, you might be wondering if a more localised application of the technique could work?

The first thing I tried was breaking object properties and function parameters onto separate lines. It seemed like a nice middle ground.

I was very wrong.

As a refresher, this is correct behaviour

interface Args {
  selector: string
  element: HTMLElement
}

function matches({ selector, element }: Args) {}

matches({
  selector: 120,
            ^^^
Argument of type "number" is not assignable to parameter of type "string"
  element: document.body,
})
  • We're still calling the function with an object that has 2 properties, but now we've split it over 2 separate lines.
  • 120 has the type number, where we need a string, so Typescript is giving a helpful error. All good.

But...

5️⃣ It still allows a typo on the property name 😭

matches({
  // @ts-expect-error
  selectar: 120,
  element: document.body,
})
  • ❌ OOPS! As before, we only wanted to ignore the type of the selector field, but we've expanded the blast-radius and now a simple typo on the property name will be ignored 😭

6️⃣ You can't apply it to an object value only 😭

It would be nice, if you could go one step further and have @ts-expect-error apply to a value only, wouldn't it...

matches({
  selector:
    // @ts-expect-error
    120,
  element: document.body,
})
  • Now we've split the key, selector, and it's value, 120, onto separate lines, in an attempt to silence the error at the value level only.
  • This doesn't work though 😭. The value 120 in isolation can never be 'invalid', it's only when considered as an assignable types to the selector field that it can be incorrect.
  • We do get a Typescript error here, but just to tell us that the directive is unused, which brings yet more confusion.
matches({
  selector:
    // @ts-expect-error
    Unused '@ts-expect-error' directive.
    120,
  element: document.body,
})

7️⃣ It affects subsequent parameters 😭

So far we've focussed on the impact @ts-expect-error or @ts-ignore can have on a single, object parameter. As if those examples were not enough, it gets even worse when we look at functions that take more than 1 argument.

Let's take a look at a 'correct' example first:

function matchesV2(selector: string, kind: 'css' | 'xpath') {

}

matchesV2(120, 'css')
          ^^^
Argument of type  number  is not assignable to parameter of type  string
  • ✅ Now we're calling a function that accepts 2 parameters - and because the first has the type string, Typescript is correctly giving us the error we expect when we try to provide 120.

So, now consider trying to 'silence' this error as before

matchesV2(
  // @ts-expect-error
  120,
  'oops!',
)
  • At first, it looks ok. We want @ts-expect-error to only apply to the very next parameter - silencing the error relating to giving 120 when a string was expected
  • But, ❌ OOPS! we've accidentally affected the second parameter too!
    • kind, in the function signature, denotes a union type with 2 members, the literal types "css" and "xpath", but we're able to provide "oops!".
  • This is terrible! Having a single @ts-expect-error on just 1 of the parameters in the function call is having an impact on subsequent parameters too!

This gets even more confusing/error-prone when you consider that preceding parameters in this style would be type-checked.

Consider another example, this time with 3 arguments.

function matchesV3(input: string, selector: string, kind: 'css' | 'xpath') {}

matchesV3(
  {},
  // @ts-expect-error
  120,
  'oops!',
)
  • Notice how all 3 of the parameters in that function call are incorrect.
  • line 4, has the type {}, where a string is expected. Typescript will check this ✅
  • line 5, our usual example, is marked @ts-expect-error, Typescript will allow this, as expected ✅
  • line 6, ❌ OOPS! Again the parameter given for kind should only be one of "css" or "xpath", but "oops!" is allowed 😭

What to do instead

To keep it brief, cast to any and move on - but do so in the most localised way possible.

❌ Before, line-level @ts-expect-error

// @ts-expect-error - fixme: example.com/ticket/123
matches({ selector: 120, element: document.body })

✅ After, localised cast to any in Typescript

matches({
  selector: 120 as any /** fixme: example.com/ticket/123 */,
  element: document.body,
})

✅ After, localised cast to any in JSDoc

Note the surrounding () parens on the value - this is a requirement.

matches({
  selector: /** @type {any} fixme: example.com/ticket/123 */(120),
  element: document.body
})

If you find it messy having type assertions and comments inline like this, you can make it even more explicit with a re-assignment + type declaration as a separate statement.

let temp: any = 120 /** fixme: example.com/ticket/123 */

// now call the function as before, without the noise
matches({ selector: temp, element: document.body })

It will work with as any here too:

let temp = 120 as any /** fixme: example.com/ticket/123 */

// now call the function as before, without the noise
matches({ selector: temp, element: document.body })

JSDoc

let temp = /** @type {any} fixme: example.com/ticket/123 */(120)

// now call the function as before, without the noise
matches({ selector: temp, element: document.body })

Works for multiple parameters too:

function matchesV2(selector: string, kind: 'css' | 'xpath') {}

-// @ts-expect-error - fixme: example.com/ticket/123
matchesV2(
-  120,
+  120 as any /** fixme: example.com/ticket/123 */,
  'css'
)

JSDoc

function matchesV2(selector: string, kind: 'css' | 'xpath') {}

matchesV2(
- 120,
+ /** @type {any} fixme: example.com/ticket/123 */(120),
  'css'
)

All examples above share the same benefits - they all silence the Typescript errors in a way that avoids unintended side effects 👌.

Summary

@ts-expect-error was designed for test-case scenarios - where you are deliberately giving an incorrect type and what to ensure the type-checker is aware of it. Use it for that, only.

@ts-ignore does operate slightly differently in some of the examples given above - but not differently enough for me to document it in this post. Regardless it's still absolutely full of odd behaviours and has unintended side effects, so I highly discourage its use too.

Instead of these brute-force approaches, use localised casting instead.

@ts-expect-error and @ts-ignore, in both regular Typescript and with JSDoc, should be seen as an absolute last resort. You must have tried all other ways of casting values before ever resorting to them.