Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for TypeScript's Assertion Functions #41179

Open
hjr3 opened this issue Dec 20, 2019 · 20 comments
Open

Add support for TypeScript's Assertion Functions #41179

hjr3 opened this issue Dec 20, 2019 · 20 comments

Comments

@hjr3
Copy link
Contributor

hjr3 commented Dec 20, 2019

🚀 Feature Proposal

Originally posted at: jestjs/jest#9146

Support TypeScript 3.7's new assertion functions.

Motivation

Jest should support this TypeScript language feature to make authoring tests simpler.

Example

Let's say I have a function under test that returns a nullable value:

export interface Data {
  anotherCoolProp: any
  somethingNeat: any
}

export function gimmeData(): Data | null {
  // Implementation details
  // ...
}

Currently, testing the results requires null checks for every assertion:

test('gimme that data', () => {
  const data = gimmeData()

  expect(data).toBeTruthy()

  expect(data!.anotherCoolProp).toEqual('coolio')
  expect(data!.somethingNeat).toEqual('neato')
})

// or:
test('gimme that data', () => {
  const data = gimmeData()

  if (!data) {
    throw new Error('Expected data to be non-null')
  }

  expect(data.anotherCoolProp).toEqual('coolio')
  expect(data.somethingNeat).toEqual('neato')
})

The first uses the ! non-null operator, which opts out of type safety and can lead to confusing error reports. The second seems non-idiomatic: why not write expect(data).toBeTruthy()?

With TypeScript 3.7 you can now define Jest's global expect to behave this way:

declare function <T>expect(value: T): asserts value is NonNullable<T>

(Although typing w/ the Jest's chaining will be a bit more involved.)

Lots of people have worked on these types, but I will start with @sandersn to see what he thinks about this.

@kevinbarabash
Copy link
Contributor

This is exciting! Having to add type refinements in addition to test assertions is a pain.

@Igmat
Copy link
Contributor

Igmat commented Jan 14, 2020

This type definitions should be updated too:

@types/assert

@nicoabie, @LinusU

If you don't have enough time, I'll probably update your definition tomorrow.

@LinusU
Copy link
Contributor

LinusU commented Jan 15, 2020

Nice, absolutely!

@LinusU
Copy link
Contributor

LinusU commented Jan 15, 2020

#41616 🚀

@G-Rath
Copy link
Contributor

G-Rath commented Feb 6, 2020

I'm pretty sure this won't be properly possible right now, since the matchers are typed to return R:

/**
 * The `expect` function is used every time you want to test a value.
 * You will rarely call `expect` by itself.
 */
interface Expect {
    /**
     * The `expect` function is used every time you want to test a value.
     * You will rarely call `expect` by itself.
     *
     * @param actual The value to apply matchers against.
     */
    <T = any>(actual: T): JestMatchers<T>;

  ...
}

type JestMatchers<T> = JestMatchersShape<Matchers<void, T>, Matchers<Promise<void>, T>>;
interface Matchers<R, T = {}> {
  ...
}

expects T isn't R, so as far as I know it's not really doable without some restructuring(which iirc is meant to happen at some point?).

@G-Rath
Copy link
Contributor

G-Rath commented Feb 6, 2020

Actually I just realised that R has the value of void, so while the above is still technically correct, I think it's primarily a case of dropping R in favor of T (or just replace usage of R with T to make it less breaking), and then using assertion types?

If people are happy with that, I'm happy to make a PR :)

@tianhuil
Copy link
Contributor

+1! What's the status here.

@pelotom
Copy link
Contributor

pelotom commented May 21, 2020

It's not clear how this could work given Jest's API. For example, what would be the type of toBeDefined? To boil it down to the essence, it seems like you'd need to be able to write something like

type Expect = <T>(x: T) => {
  toBeDefined(): asserts x is NonNullable<T>;
  // ... other methods
};

But TypeScript rejects the assertion type with "Cannot find parameter 'x'."

@bodinsamuel
Copy link
Contributor

This would be a game changer, hope someone can make progress on this 💪

@tom-sherman
Copy link

tom-sherman commented Sep 22, 2020

From what I can see the current expect API would need changes to TypeScript to support assertion functions, so I've been playing around with some alternative APIs that are possible right now. Like the current chaining API, it looks like currying is not possible either eg. expect(x)(toBeDefined()), however I've come up with this:

type Matcher<T, X extends T> = (val: T) => asserts val is X

declare function expect<T, X extends T>(val: T, matcher: Matcher<T, X>): asserts val is X

declare function toBeDefined<T>(): (val: T) => asserts val is NonNullable<T> 

// ========================= //

declare const x: number | undefined;

expect(x, toBeDefined())

x // number

Playground

If there's interest in this I can continue and publish a library that mirrors the current expect API but with the above shape. Lemme know your thoughts!

@osdiab
Copy link
Contributor

osdiab commented Mar 31, 2021

I like that format, wondering why toBeDefined needs to be invoked though, is that for allowing passing options or something?

@tom-sherman
Copy link

There's no technical reason for that specific matcher to be curried, it's more of an aesthetic choice from me. I won't go into details because I don't want to derail this issue into a bikeshedding discussion 😄

If I ever get around to publishing such a library then we can have the discussion there 😅

@tom-sherman
Copy link

Following on from my "not currently possible in TypeScript" thought:

I'm no expert in these matters, but I wonder if the reason why an expect API with assertions doesn't work is because of Typescript's lack of support for Higher Kinded Types. And if so, maybe the current API could be typed using lightweight HKTs 🤔 There is prior art with lightweight HKTs in https://github.com/gcanti/fp-ts

@tatethurston
Copy link
Contributor

tatethurston commented Nov 9, 2021

Using TypeScript's this argument gets close to the desired end result, but isn't quite correct and does not seem supported by the current TypeScript assert implementation.

interface Expect {
  <T>(x: T): Matchers<T>
}

interface Matchers<T> {
  toBeDefined(this: T): asserts this is NonNullable<T> 
}

declare const expect: Expect;

function getFoo(): number | undefined {
  return 3;
}

const foo = getFoo();
/*
 * The 'this' context of type 'Matchers<number | undefined>' is not assignable to method's 'this' of type 'number'.(2684)
Assertions require the call target to be an identifier or qualified name.(2776)
 */
expect(foo).toBeDefined();
console.log(foo)

Playground with the above

@vegerot
Copy link

vegerot commented Jan 7, 2022

Very excited to see this is being worked on. What's the status of this?

@TJSomething
Copy link

TJSomething commented Mar 18, 2022

With a slight modification to the interface, you can kind of get it to work. The big problem is that it can't apply the type assertions backwards.

interface Expect {
  <T>(x: T): Matchers<T>;
}

declare const expect: Expect;

interface Matchers<T> {
  toBeDefined(this: Matchers<T>): asserts this is Matchers<NonNullable<T>>;
  getActual(): T;
}

function getFoo(): number | undefined {
  return 3;
}

const foo = getFoo();
const x: Matchers<typeof foo> = expect(foo);
x.toBeDefined();
console.log(x.getActual().toFixed());

@OnkelTem
Copy link

OnkelTem commented Apr 8, 2022

Any updates on this?

Meanwhile I'm just using some wrappers:

// Wrapper 1
function expect_toBeDefined<T>(arg: T): asserts arg is NonNullable<T> {
  expect(arg).toBeDefined();
  //if (arg == null)  throw new Error("arg is null");
}

// Wrapper 2
function expect_not_toBeDefined<T>(arg: unknown): asserts arg is undefined | null {
  expect(arg).not.toBeDefined();
  //if (arg == null)  throw new Error("arg is not null");
}

// Tests

test("getNumberOrUnfedined(true) should be defined", () => {
  const x = getNumberOrUnfedined(true);
  console.log(x);
  expect_toBeDefined(x);
                   //^? number
});

test("getNumberOrUnfedined(false) should be undefined", () => {
  const x = getNumberOrUnfedined(false);
  console.log(x);
  expect_not_toBeDefined(x);
                       //^? undefined
});

// Functions

function getNumberOrUnfedined(returnNumber: boolean): number | undefined {
  return returnNumber ? 1 : undefined;
}

@ExE-Boss
Copy link
Contributor

ExE-Boss commented Apr 8, 2022

See also

@xuhdev
Copy link

xuhdev commented Jan 31, 2024

I improved @OnkelTem's wrapper:

export function expectToBeDefined<T>(
  arg: T,
): asserts arg is Exclude<T, undefined> {
  expect(arg).toBeDefined();
}

export function expectToBeUndefined(arg: unknown): asserts arg is undefined {
  expect(arg).toBeUndefined();
}


describe("expect toBeDefined/Undefined", () => {
  test("expectToBeDefined asserts that type is not undefined", () => {
    const arg: number | undefined = 1;
    expectToBeDefined(arg);
    const someNumber: number = arg; // TS compilation passes because arg is a number.
    someNumber;
  });

  test("expectToBeUndefined asserts that type is undefined", () => {
    const arg: number | undefined = undefined;
    expectToBeUndefined(arg);
    const someUndefined: undefined = arg; // TS compilation passes because arg is undefined.
    someUndefined;
  });
});

Note that toBeDefined/toBeUndefined only asserts whether a variable is undefined. Whether the variable is null is not involved.

@rjgotten
Copy link

rjgotten commented Feb 16, 2024

FWIW the solution here might probably be if Jest would make the matcher a second argument to the expect function.

E.g.

expect(foo, to.not.beNullOrUndefined());

Any matcher that implements particular marker interfaces in its types could then contribute to the final assert is condition of the expect, which would return a conditional type based on which of those interfaces the matcher is marked with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests