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
ofArgs
has the typestring
, but we're passing anumber
. 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 ofselector
(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 typeHTMLElement
, but we passed astring
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 beelement
. - 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 typenumber
, where we need astring
, 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 theselector
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 theselector
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 provide120
.
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 giving120
when astring
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 forkind
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.