TypeScript: Assertion functions don't work when defined as methods

TypeScript Version:

3.9.0-dev.20200220

Search Terms:

assertion signature

Code

type Constructor<T> = new (...args: any[]) => T

let str: any = 'foo'

str.toUpperCase() // str is any

function assert(condition: unknown, message: string = 'Assertion failure', ErrorConstructor: Constructor<Error> = AssertionError): asserts condition {
    if(!condition) {
        throw new ErrorConstructor(message);
    }
}

assert(typeof str == 'string')

str.toUpperCase() // str is string

class AssertionError extends Error { }

class Assertion {
    assert(condition: unknown, message: string = 'Assertion failure', ErrorConstructor: Constructor<Error> = AssertionError): asserts condition {
        if(condition) {
            throw new ErrorConstructor(message);
        }
    }
}

let azzert = new Assertion().assert

let str2: any = 'bar'

azzert(typeof str2 == 'string')  //error 2775

str2.toUpperCase() // str2 is any

Expected behavior:

Assertion functions are usable as methods of a class

Actual behavior:

Assertions require every name in the call target to be declared with an explicit type annotation.(2775) ‘azzert’ needs an explicit type annotation.

Playground Link:

https://www.typescriptlang.org/play/?ts=3.9.0-dev.20200220&ssl=1&ssc=1&pln=33&pc=34#code/C4TwDgpgBAwg9gOwM7AE4FcDGw6oDwAqAfFALxQIQDuUAFAHSMCGqA5kgFxRMIgDaAXQCUZEgQCwAKCkAbCMCgpUXHiDJQA5ADM4cDVKlL6OAKphIqGEyQRaIgPT3FaKAEsk3Xgclb0CbK6I3Eg2qMC0mIgAJq7AgQhcfgDWCHBUCAA0UAC2ECFMrBBcSq4IrOoaAIIhEGHxUFpMrjLoqBAaWQCiqKi48MhoWDjKsIhKQ7h43b2oJOTVoXGI07hCKjVhHpEIMUsIUADeUlAnblq0AITbu-EiR5Knj1DAABa9NJQ0K5Zjg9i4tFy+UKQgA3MdTgBfKTQ6SSayLWigSBwLTOVBkcgaEplDRCbxGUzmWpWGx2KCOdFuDw41jeTAyBFQBa1PbfKAQAAewAgOw87IOUFhUgZTJZdSC90eCNZEWisXiiQQKTSmRyeSQBSK6NK5Sx4r2DSaLTaHSg33643+I0tf2GUx6uDmzI2bMdqDWwUWW3lhqlTxOrnO1wViDuEIDj1e7wo1HN7ttGGtgI1WrBEaesMesOFkjkCiYAC9C6z1J8XYt4nZ6DKwt58+iAEwqXgVABGLH0cKLJbCSPAEFRTcxmlpeIp9lqMygjYA7LOAKwEtCN4xwMwWUm2BxOJSN6meEBAA

Related Issues:

Perhaps this a design limitation per the following PR?

https://github.com/microsoft/TypeScript/pull/33622 https://github.com/microsoft/TypeScript/pull/33622#issuecomment-575301357

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 31
  • Comments: 36 (2 by maintainers)

Commits related to this issue

Most upvoted comments

Assertion functions always need explicit annotations. You can write this:

let azzert: Assertion['assert'] = new Assertion().assert;

@justinmchase I think the part that is boggling your mind is that the message isn’t clear about what exactly doesn’t have an explicit type. You quoted this code, which clearly does have an explicit type:

export function exists(value: unknown): asserts value {
  assert(value);
}

But when you used it, you imported it like this:

import assert from "./assert";

assert.exists(true); // error ts(2775)

The 2775 error you are getting on that line is not about exists needing an explicit type, it’s about assert needing one. You can verify this by importing exists directly:

import { exists } from "./assert";
exists( true ); // works without the error.

So the error isn’t telling you about problems with exists, it’s telling you that your default export needs to be explicitly typed:

const defaults: {
  exists( value: unknown ): asserts value;
} = {
  exists,
};

export default defaults;

It isn’t entirely clear to me why that is the case, and it seems that making the error message clearer about where exactly the problem lies was discussed and rejected.

What I have found can work to avoid having to repeat the typings twice is to move the actual assertion functions into a separate file and then re-export them:

export * from './real-assertions';
import * as assertions from './real-assertions';
export default assertions;

I’m also not entirely clear on why this works, but it seems like it does…

Without being too repetitive, the following works for me:

type Assert = (condition: unknown, message?: string) => asserts condition;

const assert: Assert = (condition, message) => {
  if (!condition) {
    throw new Error(message);
  }
};

export default assert;

@justinmchase explicitly give it a type where you imported it.

As a prime example where you would expect this to work, but it doesn’t—in a way that seems to me at least to pretty clearly be a bug—is namespaces.

For backwards compatibility, Ember’s types have both modern module imports and re-exports from the Ember namespace. So we have:

export function assert(desc: string): never;
export function assert(desc: string, test: unknown): asserts test;
import { assert } from '@ember/debug';
assert('this works', typeof 'hello' === 'string');

And we also re-export that on the Ember namespace, with a re-export like this:

import * as EmberDebugNs from '@ember/debug';

export namespace Ember {
    // ...
    const assert: typeof EmberDebugNs.assert;
    // ...
}
const { assert } = Ember;
assert('this FAILS', typeof 'hello' === 'string');

‘assert’ needs an explicit type annotation.

I understand the constraints as specified, and I’m afraid I must disagree with the team’s conclusion about this error message

We discussed in the team room and agreed that the existing message is fine, so I didn’t change it.

—seeing as it took me about half an hour to finally find my way to this thread and understand the problem, and another half an hour to figure out how to rewrite our DefinitelyTyped tests to continue handling this! 😅

For the record, if anyone else is curious, this is how I’m ending up working around it for the types PR I’ll be pushing up shortly:

const assertDescOnly: (desc: string) => never = Ember.assert;
const assertFull: (desc: string, test: unknown) => asserts test = Ember.assert;

(I’m also going to have to document that or be ready to explain it, though gladly basically none of our TS users are likely to be doing this.)

this is working for me (node 14, ts 4.1.3):

import assertMod, { fail, ok, strictEqual, deepStrictEqual, notDeepStrictEqual, throws, doesNotThrow, ifError, rejects, doesNotReject, strict } from "assert";

type AssertT = {
  fail: typeof fail,
  ok: typeof ok,
  strictEqual: typeof strictEqual,
  deepStrictEqual: typeof deepStrictEqual,
  notDeepStrictEqual: typeof notDeepStrictEqual,
  throws: typeof throws,
  doesNotThrow: typeof doesNotThrow,
  ifError: typeof ifError,
  rejects: typeof rejects,
  doesNotReject: typeof doesNotReject,
  strict: typeof strict,
} & ((value: boolean, message?: string) => void);

const assert: AssertT = assertMod;

now you can call without having ts(2775):

assert(false, "allways throws");
assert.fail("allways throws");

I think everybody would prefer that asserts and is behave the same in the context of they definition. The current situation:

declare function makeStruct(): {
   assert(subj: unknown): asserts subj is string
   guard(subj: unknown): subj is string
}

const struct = makeStruct()

declare const subj: unknown

// ts-error:
// "Assertions require every name in the call target to be
//  declared with an explicit type annotation. 10:1:416 typescript2775"
struct.assert(subj)

// No such problem with `guard`
if (struct.guard(subj)) {
  subj // `string` as expected
}

The current restriction is highly unintuitive and reduces ts community ability to create nice assert method for schema dependent parsers/validators.

For example we could have assert method in zod library structs and typeguard unknown data like this:

import z from 'zod`

declare const subject: unknown

z.string().assert(subject)

subject // `string`

While I can at least understand the decision to consider this working as intended, the error message you get only makes sense after you discover the solution to the problem, which is not an ideal trait in an error message.

Ok, one last time, I swear I’m not being intentionally obtuse but I think I’m homing in.

I think my confusion is because, to my understanding, this function has an explicit type:

export function exists(value: unknown): asserts value {
  assert(value);
}

So when you say the type needs to be explicit its boggling my mind because I don’t get how it couldn’t be explicit.

But now, I’m wondering if you don’t mean the function’s type but the type of the argument needs to be explicit? As in the value: unknown is the issue? It needs to be non-generic and not something such as any or unknown? Is that what you mean?

I am also seeing error ts(2775) when using require instead of import

Following has error:

const assert = require('assert')

Following has no error

import assert from 'assert'

Troubleshoot Info

> tsc --version
Version 3.8.3

I wonder the reasons for it. I think the compiler infers a function/calling signature type rather than an “assertion function type”. It explains stuff like this:

function assertThing(thing: any): asserts thing is string {
  // ...
}

const assertStuff = assertThing; // the constant's type is inferred as `(thing: any) => void`

For what it’s worth this has made the Assert library usable again when checking JavaScript with Typescript.

const Assert = /** @type {typeof import('assert')} */(require('assert'))

All the type errors reported about non-explicit types are gone (thankfully).

The bulk “Close” action doesn’t let you pick which “Close” you’re doing.

Design Limitations aren’t supposed to be left open - it means we’re not aware of any plausible way to address them.

Fwiw, if t.assertSomething(val) reports that error because t is not explicitly typed, you can solve it by doing

const t2: typeof t = t;

t2.assertSomething(val);

so that t2 is explicitly annotated as having its own type.

Assertion functions always need explicit annotations. You can write this:

let azzert: Assertion['assert'] = new Assertion().assert;

Thanks for the clear explanation, this is a strange behavior from typescript. Got stumped by this error today.

Just throwing in my two-cents because I got struck by this annoying and very unhelpful bug wording today…

My ‘branding’ string code.

export type DodiAttributeCodeType = string & { _type: 'DodiAttributeCode' };

/* Delcare type to get around TS Bug */
type DodiAttributeCode = {
  guard(str: string): str is DodiAttributeCodeType;
  parse(str: string): DodiAttributeCodeType;
  assert(str: string): asserts str is DodiAttributeCodeType;
};

export const DodiAttributeCode: DodiAttributeCode = {
  guard(str: string): str is DodiAttributeCodeType {
    return !!str; /* some complex test */
  },
  assert(str: string): asserts str is DodiAttributeCodeType {
    if (!DodiAttributeCode.guard(str)) {
      throw new Error();
    }
  },
  parse(str: string): DodiAttributeCodeType {
    DodiAttributeCode.assert(str);
    return str;
  },
};
import { DodiAttributeCode, DodiAttributeCodeType } from '@core/types/DodiAttributeCode';

const a = 'hello';
const b: DodiAttributeCode = a; // TS error 2322, string not valid
DodiAttributeCode.assert(a);
const c: DodiAttributeCode = a; // TS loves it.. Time to do real work now.
type AssertsIsOneOfType = (
  item: Item,
  types: ItemClass[],
  prop: string
) => void;

const assertIsOneOfType: AssertsIsOneOfType = <T extends ItemClass[]>(
  item: Item,
  types: T,
  prop: string
): asserts item is Item & { className: T[number] } => {
  if (!types.some((type) => isOfType(item, type))) {
    throw new Error(`Cannot apply ${prop} to ${item.className}`);
  }
};

Do I really have to define all the types of this function twice just because I want to use a generic in my assertion?

If you don’t const assertIsOneOfType: AssertsIsOneOfType like this then I get the:

Assertions require every name in the call target to be declared with an explicit type annotation.ts(2775) issue.

Edit: Even then its not narrowing the type like it would in a regular boolean style type guard. But this works:

const _assertIsOneOfType = <T extends ItemClass[]>(
  item: Item,
  types: T,
  prop: string
): asserts item is Item & { className: T[number] } => {
  if (!types.some((type) => isOfType(item, type))) {
    throw new Error(`Cannot apply ${prop} to ${item.className}`);
  }
};

const assertIsOneOfType: typeof _assertIsOneOfType = _assertIsOneOfType;

Edit: I see its an issue with the const if I just declare it is a function I can type it inline:

function assertIsOneOfType<T extends ItemClass[]>(
  item: Item,
  types: T,
  prop: string
): asserts item is Item & { className: T[number] } {
  if (!types.some((type) => isOfType(item, type))) {
    throw new Error(`Cannot apply ${prop} to ${item.className}`);
  }
}

@RyanCavanaugh Can you explain in a little more detail? I don’t understand how to resolve the issue.

This also is failing with the same error:

import * as assert from "assert";
export function exists(value: unknown): asserts value {
  assert(value);
}

export default {
  exists
};

Using:

import assert from "./assert";

assert.exists(true); // error ts(2775)

How does the exists function not have an explicit annotation? Or what can I do to resolve it in this case.

If I just export the function as non-default and do the import as import { exists } from "./assert" then it works but I’d like to have them grouped on the object assert.xyz.