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

Assertion Equal bug for enums values #33

Open
3axap4eHko opened this issue Jul 22, 2024 · 10 comments
Open

Assertion Equal bug for enums values #33

3axap4eHko opened this issue Jul 22, 2024 · 10 comments

Comments

@3axap4eHko
Copy link

I consider types are equal if they are extend each other. Unfortunately it is not the case for Equal

enum Enum {
  A = "a",
  B = "b",
}

const values1 = Object.values(Enum);
const values2 = [Enum.A, Enum.B];

type AssertEqual<T, U> = T extends U ? (U extends T ? true : never) : never;

const result: AssertEqual<typeof values1, typeof values2> = true;

assert<Equal<typeof values1, typeof values2>>(true);
@garronej
Copy link
Owner

garronej commented Jul 22, 2024

Hello @3axap4eHko,

While it’s inconvenient to have two types that you know are equal being deemed not equal by assert<Equals<>>, it is ultimately just an inconvenience. On the other hand, if assert<Equals<>> validated two types as identical when they actually aren’t, it would be a massive issue because you would be operating under a false assumption.

Let me give you an example of why I cannot replace the implementation of Equals with Equals<T, U> = T extends U ? (U extends T ? true : never) : never;:

Example Image

In practice, if your type doesn’t involves functions, you can safely use ExtendsEachOther in place of Equals. I personally use this workaround sometimes for types that are too complex to be validated as equal by Equals.

I don't think there is a way to improve the implementation of Equal so that is never gives false negative unfortunately.

I will add the ExtendsEachOther<> type to tsafe tomorrow. 👍🏻

Thanks for reaching out, I hope it helps clarify things.

@3axap4eHko
Copy link
Author

Maybe it worth to name it as LooseEqual since you already have StrictEqual type assertion

@3axap4eHko
Copy link
Author

@garronej Also I've checked with function and it errors

type AssertEqual<T, U> = T extends U ? (U extends T ? true : never) : never;

type A = {  f: (x?: string) => void }
type B = {  f: (x: string) => void }

const resultFn: AssertEqual<A, B> = true; // Type 'boolean' is not assignable to type 'never'.

can you provide any false negative example to test

@garronej
Copy link
Owner

garronej commented Jul 24, 2024

@3axap4eHko, sorry my answer was wrong the example I gave in my screenshot gave a false positive just because I didn't set the test case up properly.

Here is an actual example that gives false positive:

    type Equals<T, U> = T extends U ? (U extends T ? true : false) : false;

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    type A = any[];
    type B = [number][];

    assert<Equals<A, B>>(); // Thoses type are deemed equals with the above implementation of Equals but not with the tsafe implementation.

Ref: https://stackoverflow.com/questions/53807517/how-to-test-if-two-types-are-exactly-the-same/69378855#69378855

So I think it's save to use the ExtendsEachOther implem of the Equal function as long as the types compared are guarenteed to have no any in them...

@3axap4eHko
Copy link
Author

@garronej they are equal too TypeScript playground

type AssertEqual<T, U> = T extends U ? (U extends T ? true : never) : never;

type A = any[];
type B = [number][];

const resultArrays: AssertEqual<A, B> = true;

@garronej
Copy link
Owner

garronej commented Jul 24, 2024

@3axap4eHko Well they shouldn't, A and B are not the same type here.
This is an example of a false positive.

@3axap4eHko
Copy link
Author

@garronej But they are the same

type A = any[];
type B = [number][];

comparison of these types can be simplified to compare their subtypes

type A = any;
type B = [number];
const resultArrays: AssertEqual<A, B> = true;

types are the same if you can assign them to each other, means any can be assigned to [number] and [number] can be assigned to any. Also I noticed that comparing anything to any except any returns false and true at the same time, so it is not false positive

assert<Equals<number, any>>(false); // errors
assert<Equals<number, any>>(true); // errors

assert<Equals<any, any>>(false); // succeed
assert<Equals<any, any>>(true); // succeed

Am I missing something?

@garronej
Copy link
Owner

garronej commented Jul 25, 2024

From a type theory perspective, you are correct that any value of type any[] is assignable to a value of type [number][] and vice versa.
However, from an ensemble perspective, [number][] represents only a tiny subset of any[].
In other words, while any[] encompasses all possible arrays, [number][] includes only arrays of number singletons.

For the helper type Equals to be useful in practice, two types must be considered equal if and only if the set of values that satisfy each of them is identical.

@3axap4eHko
Copy link
Author

3axap4eHko commented Jul 25, 2024

@garronej your explanation does make sense, but this assertions does not confirm your explanation

assert<Equals<any, any>>(false); // succeed
assert<Equals<any, any>>(true); // succeed

UPD: Also this assertion looks like a false negative according to your explanation

// @ts-expect-error
assert<Equals<number, any>>(false); 

@garronej
Copy link
Owner

garronej commented Jul 25, 2024

It might seem confusing, but this is actually working as expected.

There are two ways to use the assert function. When used in conjunction with Equals, you should not pass any parameters:

import { assert, type Equals } from "tsafe/assert";

assert<Equals<A, B>>(); // This will cause a type error if A and B are not the same type.

It's mainly usefull for writing type level unit test on generic functions with complex return types that get inferred from the input types. Example, I want to test my exclude() function:

const x = (["a", "b", "c"] as const).filter(exclude(["a"]));

type Got = typeof x;
type Expected = ("b" | "c")[];

assert<Equals<Got, Expected>>();

These kinds of assert statements can be removed from the distribution build as they are solely type safeguards and do not do anything at runtime.

On the other hand, you can use the assert function to make assertions on a given value and narrow down its type. For example:

import { assert } from "tsafe/assert";

type Shape = {
    kind: "circle" | "square";
    radius?: number;
    sideLength?: number;
};

export function getArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            assert(shape.radius !== undefined, "radius is required for circle");
            return Math.PI * shape.radius ** 2;
        case "square":
            assert(shape.sideLength !== undefined, "sideLength is required for square");
            return shape.sideLength ** 2;
    }
}

Here, the Shape type is not very strict. It is assumed that if the type is "circle", then the radius must be defined, and if the type is "square", the sideLength must be defined, but this is not enforced by the type system.

For instance, if we do this:

Type error example

We get type errors, and rightfully so. Using ! should be avoided at all costs since it leads to errors that are hard to trace. By using the assert function, we can state: "I know for a fact that since the kind is 'circle', the radius must be defined. If it isn't, throw an error. After this line, TypeScript can assume that radius is defined."

Assertion example

This is a very different use case, as we are actually doing something at runtime.

I think what confused you is this GIF:

Misleading example

Here, I pass false while using Equals. This is very misleading, and I should edit this example. This only makes sense when we are comparing against never. Here, the false says: "We are not supposed to ever reach this point at runtime, but if we do, throw an exception." This is kind of redundant because if our type system is failing that much, we have bigger problems (like forgetting to validate the shape of the data entering our system with something like zod).

I hope this clarifies things a bit.

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

2 participants