Skip to content

Commit

Permalink
Rewrite Equal to use the equality check from ReadonlyEquivalent e…
Browse files Browse the repository at this point in the history
…xclusively (#21)

Fixes #29
Fixes #26
Fixes #5

> Note: I extracted a very small part of this PR to
#20

This is a breaking change as I opted to remove the types that were no
longer needed. They are exported though so it's likely some people
depend on them. I can add these back as desired.

This took a lot of tinkering. This topic and this equality check is
discussed extensively at
microsoft/TypeScript#27024

The main three edge-cases this implementation worked around are:
1. Explicitly handling `any` separately
2. Supporting identity unions
3. Supporting identity intersections

The only remaining known issue with this implementation is:

```ts
  // @ts-expect-error This is the bug.
  expectTypeOf<{foo: number} & {bar: string}>().toEqualTypeOf<{foo: number; bar: string}>()
```

@shicks and I could not find a tweak to the `Equality` check to make
this work.

Instead, I added a workaround in the shape of a new `.simplified`
modifier that works similar to `.not`:

```ts
  // The workaround is the new optional .simplified modifier.
  expectTypeOf<{foo: number} & {bar: string}>().simplified.toEqualTypeOf<{foo: number; bar: string}>()
```

I'm not entirely sure what to do with documenting `.simplified` because
it's something you should never use unless you need it. The simplify
operation tends to lose information about the types being tested (e.g.,
functions become `{}` and classes lose their constructors). I'll
definitely update this PR to reference the `.simplified` modifier but I
wanted to get a review on this approach first. One option would be to
keep around all the `DeepBrand` stuff and to have `.deepBranded` or
something being the modifier instead. That would have the benefit of
preserving all the exported types making this less of a breaking change.

---------

Co-authored-by: Misha Kaletsky <[email protected]>
Co-authored-by: Misha Kaletsky <[email protected]>
  • Loading branch information
3 people authored May 10, 2023
1 parent feff1e3 commit 8f19883
Show file tree
Hide file tree
Showing 5 changed files with 717 additions and 170 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ expectTypeOf([1, 2, 3]).items.toBeNumber()
expectTypeOf([1, 2, 3]).items.not.toBeString()
```

You can also compare arrays directly:

```typescript
expectTypeOf<any[]>().not.toEqualTypeOf<number[]>()
```

Check that functions never return:

```typescript
Expand Down Expand Up @@ -420,6 +426,22 @@ class C {

expectTypeOf<typeof A>().toEqualTypeOf<typeof C>()
```

Known limitation: Intersection types can cause issues with `toEqualTypeOf`:

```typescript
// @ts-expect-error the following line doesn't compile, even though the types are arguably the same.
// See https://github.com/mmkal/expect-type/pull/21
expectTypeOf<{a: 1} & {b: 2}>().toEqualTypeOf<{a: 1; b: 2}>()
```

To workaround, you can use a mapped type:

```typescript
type Simplify<T> = {[K in keyof T]: T[K]}

expectTypeOf<Simplify<{a: 1} & {b: 2}>>().toEqualTypeOf<{a: 1; b: 2}>()
```
<!-- codegen:end -->

### Within test frameworks
Expand Down
114 changes: 71 additions & 43 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,17 @@ type ReadonlyEquivalent<X, Y> = Extends<
export type Extends<L, R> = IsNever<L> extends true ? IsNever<R> : [L] extends [R] ? true : false
export type StrictExtends<L, R> = Extends<DeepBrand<L>, DeepBrand<R>>

export type Equal<Left, Right> = And<[StrictExtends<Left, Right>, StrictExtends<Right, Left>]>
type StrictEqual<L, R> = (<T>() => T extends (L & T) | T ? true : false) extends <T>() => T extends (R & T) | T
? true
: false
? IsNever<L> extends IsNever<R>
? true
: false
: false

export type Equal<Left, Right, Branded = true> = Branded extends true
? And<[StrictExtends<Left, Right>, StrictExtends<Right, Left>]>
: StrictEqual<Left, Right>

export type Params<Actual> = Actual extends (...args: infer P) => any ? P : never
export type ConstructorParams<Actual> = Actual extends new (...args: infer P) => any
Expand All @@ -93,63 +103,78 @@ export type ConstructorParams<Actual> = Actual extends new (...args: infer P) =>
: P
: never

type MismatchArgs<B extends boolean, C extends boolean> = Eq<B, C> extends true ? [] : [never]

export interface ExpectTypeOf<Actual, B extends boolean> {
toBeAny: (...MISMATCH: MismatchArgs<IsAny<Actual>, B>) => true
toBeUnknown: (...MISMATCH: MismatchArgs<IsUnknown<Actual>, B>) => true
toBeNever: (...MISMATCH: MismatchArgs<IsNever<Actual>, B>) => true
toBeFunction: (...MISMATCH: MismatchArgs<Extends<Actual, (...args: any[]) => any>, B>) => true
toBeObject: (...MISMATCH: MismatchArgs<Extends<Actual, object>, B>) => true
toBeArray: (...MISMATCH: MismatchArgs<Extends<Actual, any[]>, B>) => true
toBeNumber: (...MISMATCH: MismatchArgs<Extends<Actual, number>, B>) => true
toBeString: (...MISMATCH: MismatchArgs<Extends<Actual, string>, B>) => true
toBeBoolean: (...MISMATCH: MismatchArgs<Extends<Actual, boolean>, B>) => true
toBeVoid: (...MISMATCH: MismatchArgs<Extends<Actual, void>, B>) => true
toBeSymbol: (...MISMATCH: MismatchArgs<Extends<Actual, symbol>, B>) => true
toBeNull: (...MISMATCH: MismatchArgs<Extends<Actual, null>, B>) => true
toBeUndefined: (...MISMATCH: MismatchArgs<Extends<Actual, undefined>, B>) => true
toBeNullable: (...MISMATCH: MismatchArgs<Not<Equal<Actual, NonNullable<Actual>>>, B>) => true
type MismatchArgs<ActualResult extends boolean, ExpectedResult extends boolean> = Eq<
ActualResult,
ExpectedResult
> extends true
? []
: [never]

export interface ExpectTypeOfOptions {
positive: boolean
branded: boolean
}
export interface ExpectTypeOf<Actual, Options extends ExpectTypeOfOptions> {
toBeAny: (...MISMATCH: MismatchArgs<IsAny<Actual>, Options['positive']>) => true
toBeUnknown: (...MISMATCH: MismatchArgs<IsUnknown<Actual>, Options['positive']>) => true
toBeNever: (...MISMATCH: MismatchArgs<IsNever<Actual>, Options['positive']>) => true
toBeFunction: (...MISMATCH: MismatchArgs<Extends<Actual, (...args: any[]) => any>, Options['positive']>) => true
toBeObject: (...MISMATCH: MismatchArgs<Extends<Actual, object>, Options['positive']>) => true
toBeArray: (...MISMATCH: MismatchArgs<Extends<Actual, any[]>, Options['positive']>) => true
toBeNumber: (...MISMATCH: MismatchArgs<Extends<Actual, number>, Options['positive']>) => true
toBeString: (...MISMATCH: MismatchArgs<Extends<Actual, string>, Options['positive']>) => true
toBeBoolean: (...MISMATCH: MismatchArgs<Extends<Actual, boolean>, Options['positive']>) => true
toBeVoid: (...MISMATCH: MismatchArgs<Extends<Actual, void>, Options['positive']>) => true
toBeSymbol: (...MISMATCH: MismatchArgs<Extends<Actual, symbol>, Options['positive']>) => true
toBeNull: (...MISMATCH: MismatchArgs<Extends<Actual, null>, Options['positive']>) => true
toBeUndefined: (...MISMATCH: MismatchArgs<Extends<Actual, undefined>, Options['positive']>) => true
toBeNullable: (
...MISMATCH: MismatchArgs<Not<Equal<Actual, NonNullable<Actual>, Options['branded']>>, Options['positive']>
) => true
toMatchTypeOf: {
<Expected>(...MISMATCH: MismatchArgs<Extends<Actual, Expected>, B>): true
<Expected>(expected: Expected, ...MISMATCH: MismatchArgs<Extends<Actual, Expected>, B>): true
<Expected>(...MISMATCH: MismatchArgs<Extends<Actual, Expected>, Options['positive']>): true
<Expected>(expected: Expected, ...MISMATCH: MismatchArgs<Extends<Actual, Expected>, Options['positive']>): true
}
toEqualTypeOf: {
<Expected>(...MISMATCH: MismatchArgs<Equal<Actual, Expected>, B>): true
<Expected>(expected: Expected, ...MISMATCH: MismatchArgs<Equal<Actual, Expected>, B>): true
<Expected>(...MISMATCH: MismatchArgs<Equal<Actual, Expected, Options['branded']>, Options['positive']>): true
<Expected>(
expected: Expected,
...MISMATCH: MismatchArgs<Equal<Actual, Expected, Options['branded']>, Options['positive']>
): true
}
toBeCallableWith: B extends true ? (...args: Params<Actual>) => true : never
toBeConstructibleWith: B extends true ? (...args: ConstructorParams<Actual>) => true : never
toBeCallableWith: Options['positive'] extends true ? (...args: Params<Actual>) => true : never
toBeConstructibleWith: Options['positive'] extends true ? (...args: ConstructorParams<Actual>) => true : never
toHaveProperty: <K extends string>(
key: K,
...MISMATCH: MismatchArgs<Extends<K, keyof Actual>, B>
) => K extends keyof Actual ? ExpectTypeOf<Actual[K], B> : true
extract: <V>(v?: V) => ExpectTypeOf<Extract<Actual, V>, B>
exclude: <V>(v?: V) => ExpectTypeOf<Exclude<Actual, V>, B>
parameter: <K extends keyof Params<Actual>>(number: K) => ExpectTypeOf<Params<Actual>[K], B>
parameters: ExpectTypeOf<Params<Actual>, B>
constructorParameters: ExpectTypeOf<ConstructorParams<Actual>, B>
thisParameter: ExpectTypeOf<ThisParameterType<Actual>, B>
instance: Actual extends new (...args: any[]) => infer I ? ExpectTypeOf<I, B> : never
returns: Actual extends (...args: any[]) => infer R ? ExpectTypeOf<R, B> : never
resolves: Actual extends PromiseLike<infer R> ? ExpectTypeOf<R, B> : never
items: Actual extends ArrayLike<infer R> ? ExpectTypeOf<R, B> : never
guards: Actual extends (v: any, ...args: any[]) => v is infer T ? ExpectTypeOf<T, B> : never
...MISMATCH: MismatchArgs<Extends<K, keyof Actual>, Options['positive']>
) => K extends keyof Actual ? ExpectTypeOf<Actual[K], Options> : true
extract: <V>(v?: V) => ExpectTypeOf<Extract<Actual, V>, Options>
exclude: <V>(v?: V) => ExpectTypeOf<Exclude<Actual, V>, Options>
parameter: <K extends keyof Params<Actual>>(number: K) => ExpectTypeOf<Params<Actual>[K], Options>
parameters: ExpectTypeOf<Params<Actual>, Options>
constructorParameters: ExpectTypeOf<ConstructorParams<Actual>, Options>
thisParameter: ExpectTypeOf<ThisParameterType<Actual>, Options>
instance: Actual extends new (...args: any[]) => infer I ? ExpectTypeOf<I, Options> : never
returns: Actual extends (...args: any[]) => infer R ? ExpectTypeOf<R, Options> : never
resolves: Actual extends PromiseLike<infer R> ? ExpectTypeOf<R, Options> : never
items: Actual extends ArrayLike<infer R> ? ExpectTypeOf<R, Options> : never
guards: Actual extends (v: any, ...args: any[]) => v is infer T ? ExpectTypeOf<T, Options> : never
asserts: Actual extends (v: any, ...args: any[]) => asserts v is infer T
? // Guard methods `(v: any) => asserts v is T` does not actually defines a return type. Thus, any function taking 1 argument matches the signature before.
// In case the inferred assertion type `R` could not be determined (so, `unknown`), consider the function as a non-guard, and return a `never` type.
// See https://github.com/microsoft/TypeScript/issues/34636
unknown extends T
? never
: ExpectTypeOf<T, B>
: ExpectTypeOf<T, Options>
: never
not: Omit<ExpectTypeOf<Actual, Not<B>>, 'not'>
branded: Omit<ExpectTypeOf<Actual, {positive: Options['positive']; branded: true}>, 'branded'>
not: Omit<ExpectTypeOf<Actual, {positive: Not<Options['positive']>; branded: Options['branded']}>, 'not'>
}
const fn: any = () => true

export type _ExpectTypeOf = {
<Actual>(actual: Actual): ExpectTypeOf<Actual, true>
<Actual>(): ExpectTypeOf<Actual, true>
<Actual>(actual: Actual): ExpectTypeOf<Actual, {positive: true; branded: false}>
<Actual>(): ExpectTypeOf<Actual, {positive: true; branded: false}>
}

/**
Expand All @@ -174,7 +199,9 @@ export type _ExpectTypeOf = {
* @description
* See the [full docs](https://npmjs.com/package/expect-type#documentation) for lots more examples.
*/
export const expectTypeOf: _ExpectTypeOf = <Actual>(_actual?: Actual): ExpectTypeOf<Actual, true> => {
export const expectTypeOf: _ExpectTypeOf = <Actual>(
_actual?: Actual,
): ExpectTypeOf<Actual, {positive: true; branded: false}> => {
const nonFunctionProperties = [
'parameters',
'returns',
Expand All @@ -186,6 +213,7 @@ export const expectTypeOf: _ExpectTypeOf = <Actual>(_actual?: Actual): ExpectTyp
'instance',
'guards',
'asserts',
'branded',
] as const
type Keys = keyof ExpectTypeOf<any, any>

Expand Down Expand Up @@ -220,5 +248,5 @@ export const expectTypeOf: _ExpectTypeOf = <Actual>(_actual?: Actual): ExpectTyp
const getterProperties: readonly Keys[] = nonFunctionProperties
getterProperties.forEach((prop: Keys) => Object.defineProperty(obj, prop, {get: () => expectTypeOf({})}))

return obj as ExpectTypeOf<Actual, true>
return obj as ExpectTypeOf<Actual, {positive: true; branded: false}>
}
Loading

0 comments on commit 8f19883

Please sign in to comment.