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.

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.

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

The main three work-arounds this implementation added are:
1. Explicitly handling `any` separately
2. Supporting identity unions
3. Supporting identity intersections

The only known issue is this case:

```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.
  • Loading branch information
trevorade committed Nov 3, 2022
1 parent dcd6422 commit 7ea9e36
Show file tree
Hide file tree
Showing 3 changed files with 655 additions and 232 deletions.
118 changes: 28 additions & 90 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,79 +12,15 @@ export type IsAny<T> = [T] extends [Secret] ? Not<IsNever<T>> : false
export type IsUnknown<T> = [unknown] extends [T] ? Not<IsAny<T>> : false
export type IsNeverOrAny<T> = Or<[IsNever<T>, IsAny<T>]>

/**
* Recursively walk a type and replace it with a branded type related to the original. This is useful for
* equality-checking stricter than `A extends B ? B extends A ? true : false : false`, because it detects
* the difference between a few edge-case types that vanilla typescript doesn't by default:
* - `any` vs `unknown`
* - `{ readonly a: string }` vs `{ a: string }`
* - `{ a?: string }` vs `{ a: string | undefined }`
*/
export type DeepBrand<T> = IsNever<T> extends true
? {type: 'never'}
: IsAny<T> extends true
? {type: 'any'}
: IsUnknown<T> extends true
? {type: 'unknown'}
: T extends string | number | boolean | symbol | bigint | null | undefined | void
? {
type: 'primitive'
value: T
}
: T extends new (...args: any[]) => any
? {
type: 'constructor'
params: ConstructorParams<T>
instance: DeepBrand<InstanceType<Extract<T, new (...args: any) => any>>>
}
: T extends (...args: infer P) => infer R // avoid functions with different params/return values matching
? {
type: 'function'
params: DeepBrand<P>
return: DeepBrand<R>
this: DeepBrand<ThisParameterType<T>>
}
: T extends any[]
? {
type: 'array'
items: {[K in keyof T]: T[K]}
}
: {
type: 'object'
properties: {[K in keyof T]: DeepBrand<T[K]>}
readonly: ReadonlyKeys<T>
required: RequiredKeys<T>
optional: OptionalKeys<T>
constructorParams: DeepBrand<ConstructorParams<T>>
}

export type RequiredKeys<T> = Extract<
{
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T],
keyof T
>
export type OptionalKeys<T> = Exclude<keyof T, RequiredKeys<T>>

// adapted from some answers to https://github.com/type-challenges/type-challenges/issues?q=label%3A5+label%3Aanswer
// prettier-ignore
export type ReadonlyKeys<T> = Extract<{
[K in keyof T]-?: ReadonlyEquivalent<
{[_K in K]: T[K]},
{-readonly [_K in K]: T[K]}
> extends true ? never : K;
}[keyof T], keyof T>;

// prettier-ignore
type ReadonlyEquivalent<X, Y> = Extends<
(<T>() => T extends X ? true : false),
(<T>() => T extends Y ? true : false)
>

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>]>
export type Simplify<T> = {[K in keyof T]: T[K]}
type MaybeSimplify<T, S> = S extends true ? Simplify<T> : T

export type Equal<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 Params<Actual> = Actual extends (...args: infer P) => any ? P : never
export type ConstructorParams<Actual> = Actual extends new (...args: infer P) => any
Expand All @@ -95,7 +31,7 @@ export type ConstructorParams<Actual> = Actual extends new (...args: infer P) =>

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

export interface ExpectTypeOf<Actual, B extends boolean> {
export interface ExpectTypeOf<Actual, B extends boolean, S = false> {
toBeAny: (...MISMATCH: MismatchArgs<IsAny<Actual>, B>) => true
toBeUnknown: (...MISMATCH: MismatchArgs<IsUnknown<Actual>, B>) => true
toBeNever: (...MISMATCH: MismatchArgs<IsNever<Actual>, B>) => true
Expand All @@ -111,39 +47,40 @@ export interface ExpectTypeOf<Actual, B extends boolean> {
toBeUndefined: (...MISMATCH: MismatchArgs<Extends<Actual, undefined>, B>) => true
toBeNullable: (...MISMATCH: MismatchArgs<Not<Equal<Actual, NonNullable<Actual>>>, B>) => 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, MaybeSimplify<Expected, S>>, B>): true
<Expected>(expected: Expected, ...MISMATCH: MismatchArgs<Extends<Actual, MaybeSimplify<Expected, S>>, B>): 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, MaybeSimplify<Expected, S>>, B>): true
<Expected>(expected: Expected, ...MISMATCH: MismatchArgs<Equal<Actual, MaybeSimplify<Expected, S>>, B>): true
}
toBeCallableWith: B extends true ? (...args: Params<Actual>) => true : never
toBeConstructibleWith: B 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
) => K extends keyof Actual ? ExpectTypeOf<Actual[K], B, S> : true
extract: <V>(v?: V) => ExpectTypeOf<Extract<Actual, V>, B, S>
exclude: <V>(v?: V) => ExpectTypeOf<Exclude<Actual, V>, B, S>
parameter: <K extends keyof Params<Actual>>(number: K) => ExpectTypeOf<Params<Actual>[K], B, S>
parameters: ExpectTypeOf<Params<Actual>, B, S>
constructorParameters: ExpectTypeOf<ConstructorParams<Actual>, B, S>
thisParameter: ExpectTypeOf<ThisParameterType<Actual>, B, S>
instance: Actual extends new (...args: any[]) => infer I ? ExpectTypeOf<I, B, S> : never
returns: Actual extends (...args: any[]) => infer R ? ExpectTypeOf<R, B, S> : never
resolves: Actual extends PromiseLike<infer R> ? ExpectTypeOf<R, B, S> : never
items: Actual extends ArrayLike<infer R> ? ExpectTypeOf<R, B, S> : never
guards: Actual extends (v: any, ...args: any[]) => v is infer T ? ExpectTypeOf<T, B, S> : 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, B, S>
: never
not: ExpectTypeOf<Actual, Not<B>>
simplified: Omit<ExpectTypeOf<Simplify<Actual>, B, true>, 'simplified'>
not: Omit<ExpectTypeOf<Actual, Not<B>, S>, 'simplified'|'not'>
}
const fn: any = () => true

Expand Down Expand Up @@ -186,6 +123,7 @@ export const expectTypeOf: _ExpectTypeOf = <Actual>(_actual?: Actual): ExpectTyp
'instance',
'guards',
'asserts',
'simplified',
] as const
type Keys = keyof ExpectTypeOf<any, any>

Expand Down

0 comments on commit 7ea9e36

Please sign in to comment.