Most @ts-expect-error directives suppress more than you intended.
TL;DR
The directive silences every error on the next line, not just the one you meant. Split the expression so it sits on the argument that’s actually wrong instead of above the whole call.
// Silences the entire line, indefinitely. // @ts-expect-error -- `name` must be a string. createUser( 42, 'admin', teamId ); // Silences one argument. createUser( // @ts-expect-error -- `name` must be a string. 42, 'admin', teamId );
The pitch
@ts-expect-error is the good escape hatch. Unlike @ts-ignore, it complains when the error it suppresses goes away, so you can’t leave dead suppressions lying around forever. That’s the pitch, and it’s mostly true.
The catch is what the second form buys you, and what the first one quietly gives up.
A test that lies
Here’s a function with a runtime guard, and a test that proves the guard works:
function createUser( name: string, role: Role, teamId: string ): User;
// @ts-expect-error -- `name` must be a string; checking the runtime guard rejects it. expect( () => createUser( 42, 'admin', teamId ) ).toThrow();
This is far and away the most common reason to reach for the directive. You’re passing something invalid on purpose, because that’s the entire point of the test. The comment even says which argument is the deliberate one.
TypeScript doesn’t read comments. It reads line numbers. The whole call sits on one line, so the whole call is now unchecked:
// Typo in the function name. Still compiles. // @ts-expect-error -- `name` must be a string; checking the runtime guard rejects it. expect( () => createUsr( 42, 'admin', teamId ) ).toThrow(); // `'admin'` isn't a valid Role any more. Still compiles. // @ts-expect-error -- `name` must be a string; checking the runtime guard rejects it. expect( () => createUser( 42, 'administrator', teamId ) ).toThrow();
You wrote a narrow, documented suppression. You got a blanket one.
Then somebody changes the signature
This is the part that actually costs you something.
Add a required orgId parameter to createUser. Change Role from a string union to an enum. Brand teamId. Every call site in the codebase lights up, which is the whole reason you’re paying the TypeScript tax in the first place. Every call site except this one, which has a note attached explaining that errors here are expected and fine.
And the directive won’t tell you it’s gone stale, because @ts-expect-error only reports itself as unused when there are zero errors left on the line. The name problem is still sitting there keeping it “used”. So the safety net that makes this better than @ts-ignore never fires.
The test still passes, too. createUser still throws, just for a completely different reason than the one you were testing. A bare .toThrow() on a blanket-suppressed line is a test that has quietly lost the ability to fail correctly, and nothing in CI is ever going to mention it.
Point at the smallest thing you can
Break the call up and put the directive directly above the bit that’s actually wrong:
expect( () =>
createUser(
// @ts-expect-error -- checking the runtime guard rejects a non-string name.
42,
'admin',
teamId
)
).toThrow();
Now it covers one argument. Typo the function name and TypeScript still yells at you. Break the role, and it still yells. Add a required parameter, and it still yells, because argument-count errors get reported at the call rather than at any particular argument. Fix the underlying typing, and you finally get the “unused directive” error you were promised.
It works inside object literals too
Nearly every React developer has hit this one, since CSS custom properties aren’t part of CSSProperties:
<div
className="card"
style={{
// @ts-expect-error -- csstype doesn't allow custom properties.
'--card-accent': accent,
maxInlineSize: width,
paddingBlock: spacing,
}}
>
One property suppressed, everything else still checked. Misspell maxInlineSize or hand paddingBlock a Date and you’ll hear about it.
Move that same directive up to the style prop and the whole style object becomes an untyped blob. Move it above the <div>, and you’ve switched off checking for every attribute on the element.
Partial mocks
The other place these breed. Partial mocks are always type errors, and the fix is always to shut them up:
mockedUseAuth.mockReturnValue(
// @ts-expect-error -- partial mock; the component only reads `isLoggedIn`.
{ isLoggedIn: true }
);
Collapse that to one line, and the suppression stops being about the shape of the mock. It now covers mockedUseAuth itself and the mockReturnValue call, so renaming the hook or misspelling the method goes unnoticed, in a file whose entire job is noticing that sort of thing.
The narrow version also says something the blanket version can’t: this object literal is deliberately incomplete, and nothing else here is.
What about overloads?
Overloads look like they should break this. When no signature matches, you get No overload matches this call, which reads like a complaint about the call as a whole rather than about any one argument, and a per-argument directive would be no use against that.
It isn’t. TypeScript anchors that diagnostic to the first argument that failed to match, so narrowing works here too:
declare function ov( a: string, b: number ): void; declare function ov( a: boolean, b: number ): void; ov( // @ts-expect-error -- deliberately bad first argument. 42, 1 );
That compiles cleanly. So does the same thing with three overloads, or five, and so does a real one like addEventListener. Even when the overloads fail at different argument positions, the error still lands on an argument rather than on the call.
Where it really doesn’t work
Two cases:
Argument count. Expected 2 arguments, but got 1 is reported against the call expression, because no single argument is to blame. A directive on an argument won’t touch it. That’s a feature, though. It’s exactly why the narrow form still catches a newly added required parameter when the blanket form swallows it.
Statement-level errors, like an unresolved import or an assignment whose target is wrong. Nothing to narrow to. Put the directive where it has to go and make the description count, because it’s the only thing telling the next person what’s intentionally broken.
Won’t Prettier just undo all this?
Reasonable worry, since the whole technique rests on line breaks. Prettier will happily collapse one( 42, 1 ) back onto a single line, but it never collapses an argument list that contains a comment.
@ts-expect-error is a comment, so the layout holds itself in place. No // prettier-ignore, no filler.
The feature that would make this unnecessary
All of the above is a workaround. The obvious fix would be to name the error you’re suppressing, the way ESLint lets you write // eslint-disable-next-line no-console rather than switching off every rule on the line. TypeScript has no equivalent. There’s no // @ts-expect-error TS2345, so position is the only granularity on offer.
That’s been requested since 2017 in microsoft/TypeScript#19139, which is still open, still labelled “Revisit”, and has picked up more than 150 comments along the way. The original report isn’t even about arguments: it’s a @ts-ignore on a switch case, meant to silence --noFallthroughCasesInSwitch, that silences everything else on that line as well.
function countDown( n: number ): void {
switch ( n ) {
// @ts-ignore
case 1:
console.log( '1' );
// intentional fall through
case 0:
console.log( '0' );
}
}
Change that to case '1': and it still compiles, which is the same failure this post has been circling from the start. Until the issue is resolved, where you put the comment is the only lever you have.
In practice
Put the directive on the tightest line that still reports the error, and reformat the expression so that’s possible. Write a description saying what’s wrong and why you can’t fix it here (@typescript-eslint/ban-ts-comment can enforce this via descriptionFormat, which is worth turning on).
Name the thing that would let you delete it, too. “Not yet in @types/react” and “csstype doesn’t allow custom properties” both point at something that will eventually change. A suppression with an exit condition is a TODO. One without is just a hole.
And if you find a directive sitting above a whole multi-argument call, go and look at what else it’s covering. Usually more than you’d like.