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

Make assert, truthy and falsy typeguards #3233

Merged
merged 6 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions test-types/import-in-cts/assertions-as-type-guards.cts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import {expectType} from 'tsd';
type Expected = {foo: 'bar'};
const expected: Expected = {foo: 'bar'};

test('assert', t => {
const actual = expected as Expected | undefined;
if (t.truthy(actual)) {
expectType<Expected>(actual);
} else {
expectType<undefined>(actual);
}
});

test('deepEqual', t => {
const actual: unknown = {};
if (t.deepEqual(actual, expected)) {
Expand Down Expand Up @@ -32,9 +41,28 @@ test('false', t => {
}
});

test('falsy', t => {
type Actual = Expected | undefined | false | 0 | '' | 0n;
const actual = undefined as Actual;
if (t.falsy(actual)) {
expectType<Exclude<Actual, Expected>>(actual);
} else {
expectType<Expected>(actual);
}
});

test('true', t => {
const actual: unknown = false;
if (t.true(actual)) {
expectType<true>(actual);
}
});

test('truthy', t => {
const actual = expected as Expected | undefined;
if (t.truthy(actual)) {
expectType<Expected>(actual);
} else {
expectType<undefined>(actual);
}
});
28 changes: 28 additions & 0 deletions test-types/module/assertions-as-type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import test from '../../entrypoints/main.mjs';
type Expected = {foo: 'bar'};
const expected: Expected = {foo: 'bar'};

test('assert', t => {
const actual = expected as Expected | undefined;
if (t.truthy(actual)) {
expectType<Expected>(actual);
} else {
expectType<undefined>(actual);
}
});

test('deepEqual', t => {
const actual: unknown = {};
if (t.deepEqual(actual, expected)) {
Expand Down Expand Up @@ -33,9 +42,28 @@ test('false', t => {
}
});

test('falsy', t => {
type Actual = Expected | undefined | false | 0 | '' | 0n;
const actual = undefined as Actual;
if (t.falsy(actual)) {
expectType<Exclude<Actual, Expected>>(actual);
} else {
expectType<Expected>(actual);
}
});

test('true', t => {
const actual: unknown = false;
if (t.true(actual)) {
expectType<true>(actual);
}
});

test('truthy', t => {
const actual = expected as Expected | undefined;
if (t.truthy(actual)) {
expectType<Expected>(actual);
} else {
expectType<undefined>(actual);
}
});
8 changes: 5 additions & 3 deletions types/assertions.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,14 @@ export type Assertions = {
truthy: TruthyAssertion;
};

type Falsy = false | 0 | -0 | 0n | '' | null | undefined;
ZachHaber marked this conversation as resolved.
Show resolved Hide resolved

export type AssertAssertion = {
/**
* Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean
* indicating whether the assertion passed.
*/
(actual: any, message?: string): boolean;
<T>(actual: T, message?: string): actual is Exclude<T, Falsy>;

/** Skip this assertion. */
skip(actual: any, message?: string): void;
Expand Down Expand Up @@ -192,7 +194,7 @@ export type FalsyAssertion = {
* Assert that `actual` is [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), returning a boolean
* indicating whether the assertion passed.
*/
(actual: any, message?: string): boolean;
<T>(actual: T, message?: string): actual is T extends Falsy ? T :never;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think never is correct here.

Copy link
Contributor Author

@ZachHaber ZachHaber Aug 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can use anything besides never there and have it work correctly. The only things that typescript doesn't complain about using there are never and any or a subset of the T type. If you make it any then falsy(true) thinks it's falsy.

This is also the exact opposite condition I setup in truthy and assert:
actual is Exclude<T,Falsy> where Exclude is Exclude<T,U> = T extends U ? never : T becomes actual is T extends Falsy ? never : T which is the opposite condition of the falsy assertion.

I included the test-types with both the if case and the else case so you can see the types as they work. For further showcasing of the types, I setup a quick Playground example if you want to try changing what the assertions are.

Playing around with it, the current conditions don't work well for number and string primitives. Typescript seems to not recognize that 0 is a subset of number unless you use an as const expression with it. Similar results for an empty string ('') but even more wacky as '' will make typescript think it is truthy with a value of '', but '' as const makes typescript recognize it as falsy. Typescript also doesn't separate out 0 or '' from their primitive values.

So far, I think I'm having the most accurate approach using (playground link)

function falsy<T>(actual: T): actual is T extends Exclude<T, Falsy>? never : T {
    return !actual;
};
function truthy<T>(actual: T): actual is Exclude<T, Falsy> {
    return !!actual;
};

In which case the only remaining issue is that for a type of number or string, typescript doesn't think that they could ever be falsy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can use anything besides never there and have it work correctly.

You're right. I read the signature incorrectly.

So far, I think I'm having the most accurate approach using (playground link)

👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With your suggestion of multiple overloads, that partially works, but runs into an issue where union types would fall back to the base behavior of the generic rather than the behavior of the overload.

I think I've cracked it for falsy, but I can't get the same to work for truthy as Typescript has no way to just invert a type. playground - As you can see from the screenshot, falsy works great, but truthy is missing 0 in the else clause, and I don't think there's really anything we can do about it...
image

type FalsyValue = false | 0 | 0n | '' | null | undefined;
type Falsy<T> = T extends Exclude<T, FalsyValue> ? (T extends number | string | bigint ? T & FalsyValue : never) : T;

function falsy<T>(actual: T): actual is Falsy<T> {
    return !actual;
};
function truthy<T>(actual: T): actual is T extends Falsy<T> ? never : T {
    return !!actual;
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but truthy is missing 0 in the else clause, and I don't think there's really anything we can do about it...

I would mention this in the doc comments.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work to have specific overloads?

Suggested change
<T>(actual: T, message?: string): actual is T extends Falsy ? T :never;
(actual: 0): true;
(actual: ''): true;
<T>(actual: T, message?: string): actual is T extends Falsy ? T :never;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<T>(actual: T, message?: string): actual is T extends Falsy ? T :never;
<T>(actual: T, message?: string): actual is T extends Falsy ? T : never;


/** Skip this assertion. */
skip(actual: any, message?: string): void;
Expand Down Expand Up @@ -337,7 +339,7 @@ export type TruthyAssertion = {
* Assert that `actual` is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), returning a boolean
* indicating whether the assertion passed.
*/
(actual: any, message?: string): boolean;
<T>(actual: T, message?: string): actual is Exclude<T, Falsy>;

/** Skip this assertion. */
skip(actual: any, message?: string): void;
Expand Down