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

toMatchObjectType + toExtend - replacements for toMatchTypeOf #126

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
73 changes: 52 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ See below for lots more examples.
- [Documentation](#documentation)
- [Features](#features)
- [Why is my assertion failing?](#why-is-my-assertion-failing)
- [Where is `.toExtend`?](#where-is-toextend)
- [Where is `.toMatchTypeOf`?](#where-is-tomatchtypeof)
- [Internal type helpers](#internal-type-helpers)
- [Error messages](#error-messages)
- [Concrete "expected" objects vs type arguments](#concrete-expected-objects-vs-type-arguments)
Expand Down Expand Up @@ -89,40 +89,73 @@ expectTypeOf({a: 1}).toEqualTypeOf({a: 2})
expectTypeOf({a: 1, b: 1}).toEqualTypeOf<{a: number}>()
```

To allow for extra properties, use `.toMatchTypeOf`. This is roughly equivalent to an `extends` constraint in a function type argument.:
To allow for extra properties on an object type, use `.toMatchObjectType`. This is a strict check, but only on the subset of keys that are in the expected type:

```typescript
expectTypeOf({a: 1, b: 1}).toMatchTypeOf<{a: number}>()
expectTypeOf({a: 1, b: 1}).toMatchObjectType<{a: number}>()
```

`.toEqualTypeOf` and `.toMatchTypeOf` both fail on missing properties:
`.toMatchObjectType` can check partial matches on deeply nested objects:

```typescript
const user = {
email: 'a@b.com',
name: 'John Doe',
address: {street: '123 2nd St', city: 'New York', zip: '10001', state: 'NY', country: 'USA'},
}

expectTypeOf(user).toMatchObjectType<{name: string; address: {city: string}}>()
```

To check that a type extends another type, use `.toExtend`:

```typescript
expectTypeOf('some string').toExtend<string | boolean>()
// @ts-expect-error
expectTypeOf({a: 1}).toExtend<{b: number}>()
```

`.toExtend` can be used with object types, but `.toMatchObjectType` is usually a better choice when dealing with objects, since it's stricter:

```typescript
expectTypeOf({a: 1, b: 2}).toExtend<{a: number}>() // avoid this
expectTypeOf({a: 1, b: 2}).toMatchObjectType<{a: number}>() // prefer this
```

`.toEqualTypeOf`, `.toMatchObjectType`, and `.toExtend` all fail on missing properties:
Copy link
Contributor

@mrazauskas mrazauskas Nov 15, 2024

Choose a reason for hiding this comment

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

As well as missing optional properties? (No time to try out. Sorry.)

Also I was wondering, why you don’t use .not in these examples? Because // @ts-expect-error makes these assertions pass also with older versions of this library (playground).

Copy link
Owner Author

Choose a reason for hiding this comment

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

As well as missing optional properties? (No time to try out. Sorry.)

toEqualTypeOf and toMatchObjectType fail on missing optional properties, but toExtend doesn't. Added some docs + tests to cover this explicitly though.

why you don’t use .not in these examples

Just because these examples are essentially the docs and .not hasn't been introduced yet. I'd be open to reordering stuff to put .not higher up, you raise a good point - it's less prone to "wrong" errors.

Copy link
Contributor

@mrazauskas mrazauskas Nov 15, 2024

Choose a reason for hiding this comment

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

To be honest, it was my stupid mistake. I just copied this code to TS Playground and tried to mess up something. All worked really really well (including .not). Until.. I realised that this was because of // @ts-expect-error..


```typescript
// @ts-expect-error
expectTypeOf({a: 1}).toEqualTypeOf<{a: number; b: number}>()
// @ts-expect-error
expectTypeOf({a: 1}).toMatchTypeOf<{a: number; b: number}>()
expectTypeOf({a: 1}).toMatchObjectType<{a: number; b: number}>()
// @ts-expect-error
expectTypeOf({a: 1}).toExtend<{a: number; b: number}>()
```

Another example of the difference between `.toMatchTypeOf` and `.toEqualTypeOf`, using generics. `.toMatchTypeOf` can be used for "is-a" relationships:
Another example of the difference between `.toExtend`, `.toMatchObjectType`, and `.toEqualTypeOf`. `.toExtend` can be used for "is-a" relationships:

```typescript
type Fruit = {type: 'Fruit'; edible: boolean}
type Apple = {type: 'Fruit'; name: 'Apple'; edible: true}

expectTypeOf<Apple>().toMatchTypeOf<Fruit>()
expectTypeOf<Apple>().toExtend<Fruit>()

// @ts-expect-error
expectTypeOf<Fruit>().toMatchTypeOf<Apple>()
// @ts-expect-error - the `editable` property isn't an exact match. In `Apple`, it's `true`, which extends `boolean`, but they're not identical.
expectTypeOf<Apple>().toMatchObjectType<Fruit>()

// @ts-expect-error
// @ts-expect-error - Apple is not an identical type to Fruit, it's a subtype
expectTypeOf<Apple>().toEqualTypeOf<Fruit>()

// @ts-expect-error - Apple is a Fruit, but not vice versa
expectTypeOf<Fruit>().toExtend<Apple>()
```

Assertions can be inverted with `.not`:

```typescript
expectTypeOf({a: 1}).not.toMatchTypeOf({b: 1})
expectTypeOf({a: 1}).not.toExtend<{b: 1}>()
expectTypeOf({a: 1}).not.toMatchObjectType<{b: 1}>()
```

`.not` can be easier than relying on `// @ts-expect-error`:
Expand All @@ -131,9 +164,9 @@ expectTypeOf({a: 1}).not.toMatchTypeOf({b: 1})
type Fruit = {type: 'Fruit'; edible: boolean}
type Apple = {type: 'Fruit'; name: 'Apple'; edible: true}

expectTypeOf<Apple>().toMatchTypeOf<Fruit>()
expectTypeOf<Apple>().toExtend<Fruit>()

expectTypeOf<Fruit>().not.toMatchTypeOf<Apple>()
expectTypeOf<Fruit>().not.toExtend<Apple>()
expectTypeOf<Apple>().not.toEqualTypeOf<Fruit>()
```

Expand Down Expand Up @@ -230,8 +263,8 @@ expectTypeOf(1).not.toBeBigInt()
Detect assignability of unioned types:

```typescript
expectTypeOf<number>().toMatchTypeOf<string | number>()
expectTypeOf<string | number>().not.toMatchTypeOf<number>()
expectTypeOf<number>().toExtend<string | number>()
expectTypeOf<string | number>().not.toExtend<number>()
```

Use `.extract` and `.exclude` to narrow down complex union types:
Expand Down Expand Up @@ -578,13 +611,13 @@ Detect the difference between regular and `readonly` properties:
type A1 = {readonly a: string; b: string}
type E1 = {a: string; b: string}

expectTypeOf<A1>().toMatchTypeOf<E1>()
expectTypeOf<A1>().toExtend<E1>()
expectTypeOf<A1>().not.toEqualTypeOf<E1>()

type A2 = {a: string; b: {readonly c: string}}
type E2 = {a: string; b: {c: string}}

expectTypeOf<A2>().toMatchTypeOf<E2>()
expectTypeOf<A2>().toExtend<E2>()
expectTypeOf<A2>().not.toEqualTypeOf<E2>()
```

Expand Down Expand Up @@ -674,8 +707,6 @@ class B {
foo() {
// @ts-expect-error
expectTypeOf(this).toEqualTypeOf(this)
// @ts-expect-error
expectTypeOf(this).toMatchTypeOf(this)
}
}

Expand Down Expand Up @@ -710,9 +741,9 @@ expectTypeOf<{a: {b: 1} & {c: 1}}>().toEqualTypeOf<{a: {b: 1; c: 1}}>()
expectTypeOf<{a: {b: 1} & {c: 1}}>().branded.toEqualTypeOf<{a: {b: 1; c: 1}}>()
```

### Where is `.toExtend`?
### Where is `.toMatchTypeOf`?

A few people have asked for a method like `toExtend` - this is essentially what `toMatchTypeOf` is. There are some cases where it doesn't _precisely_ match the `extends` operator in TypeScript, but for most practical use cases, you can think of this as the same thing.
The `.toMatchTypeOf` method is deprecated, in favour of `.toMatchObjectType` (when strictly checking against an object type with a subset of keys), or `.toExtend` (when checking for "is-a" relationships). There are no foreseeable plans to remove `.toMatchTypeOf`, but there's no reason to continue using it - `.toMatchObjectType` is stricter, and `.toExtend` is identical.

### Internal type helpers

Expand Down
71 changes: 70 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ import type {
OverloadReturnTypes,
OverloadsNarrowedByParameters,
} from './overloads'
import type {AValue, Extends, MismatchArgs, StrictEqualUsingTSInternalIdenticalToOperator} from './utils'
import type {
AValue,
DeepPickMatchingProps,
Extends,
IsUnion,
MismatchArgs,
Not,
StrictEqualUsingTSInternalIdenticalToOperator,
} from './utils'

export * from './branding' // backcompat, consider removing in next major version
export * from './messages' // backcompat, consider removing in next major version
Expand All @@ -36,6 +44,31 @@ export * from './utils' // backcompat, consider removing in next major version
* {@linkcode expectTypeOf()} utility.
*/
export interface PositiveExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {positive: true; branded: false}> {
/**
* Similar to jest's `expect(...).toMatchObject(...)` but for types.
* Deeply "picks" the properties of the actual type based on the expected type, then performs a strict check to make sure the types match `Expected`.
*
* Note: optional properties on the expected type are not allowed to be missing on the actual type.
*/
toMatchObjectType: <
Expected extends IsUnion<Expected> extends true
? 'toMatchObject does not support union types'
: Not<Extends<Expected, Record<string, unknown>>> extends true
? 'toMatchObject only supports object types'
: StrictEqualUsingTSInternalIdenticalToOperator<DeepPickMatchingProps<Actual, Expected>, Expected> extends true
? unknown
: MismatchInfo<DeepPickMatchingProps<Actual, Expected>, Expected>,
>(
...MISMATCH: MismatchArgs<
StrictEqualUsingTSInternalIdenticalToOperator<DeepPickMatchingProps<Actual, Expected>, Expected>,
true
>
) => true

toExtend<Expected extends Extends<Actual, Expected> extends true ? unknown : MismatchInfo<Actual, Expected>>(
...MISMATCH: MismatchArgs<Extends<Actual, Expected>, true>
): true

toEqualTypeOf: {
/**
* Uses TypeScript's internal technique to check for type "identicalness".
Expand Down Expand Up @@ -118,8 +151,20 @@ export interface PositiveExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {
): true
}

toExtend: <Expected extends Extends<Actual, Expected> extends true ? unknown : MismatchInfo<Actual, Expected>>(
...MISMATCH: MismatchArgs<Extends<Actual, Expected>, true>
) => true

/**
* @deprecated - use either `toMatchObject` or `toExtend` instead
* - use `toMatchObjectType` to perform a strict check on a subset of your type's keys
* - use `toExtend` to check if your type extends the expected type
*/
toMatchTypeOf: {
/**
* @deprecated - use either `toMatchObject` or `toExtend` instead
* - use `toMatchObjectType` to perform a strict check on a subset of your type's keys
* - use `toExtend` to check if your type extends the expected type
* A less strict version of {@linkcode toEqualTypeOf | .toEqualTypeOf()}
* that allows for extra properties.
* This is roughly equivalent to an `extends` constraint
Expand Down Expand Up @@ -147,6 +192,9 @@ export interface PositiveExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {
): true

/**
* @deprecated - use either `toMatchObject` or `toExtend` instead
* - use `toMatchObjectType` to perform a strict check on a subset of your type's keys
* - use `toExtend` to check if your type extends the expected type
* A less strict version of {@linkcode toEqualTypeOf | .toEqualTypeOf()}
* that allows for extra properties.
* This is roughly equivalent to an `extends` constraint
Expand Down Expand Up @@ -265,6 +313,25 @@ export interface PositiveExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {
* Represents the negative expectation type for the {@linkcode Actual} type.
*/
export interface NegativeExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {positive: false}> {
/**
* Similar to jest's `expect(...).toMatchObject(...)` but for types.
* Deeply "picks" the properties of the actual type based on the expected type, then performs a strict check to make sure the types match `Expected`.
*
* @example
* ```ts
* expectTypeOf({a: 1, b: 2}).toMatchObjectType<{a: number}> // passes
* expectTypeOf({a: 1, b: 2}).toMatchObjectType<{a: string}> // fails
* ```
*/
toMatchObjectType: <Expected>(
...MISMATCH: MismatchArgs<
StrictEqualUsingTSInternalIdenticalToOperator<Pick<Actual, keyof Actual & keyof Expected>, Expected>,
false
>
) => true

toExtend<Expected>(...MISMATCH: MismatchArgs<Extends<Actual, Expected>, false>): true

toEqualTypeOf: {
/**
* Uses TypeScript's internal technique to check for type "identicalness".
Expand Down Expand Up @@ -933,6 +1000,8 @@ export const expectTypeOf: _ExpectTypeOf = <Actual>(
toMatchTypeOf: fn,
toEqualTypeOf: fn,
toBeConstructibleWith: fn,
toMatchObjectType: fn,
toExtend: fn,
toBeCallableWith: expectTypeOf,
extract: expectTypeOf,
exclude: expectTypeOf,
Expand Down
23 changes: 23 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,26 @@ export type TuplifyUnion<Union, LastElement = LastOf<Union>> =
* Convert a union like `1 | 2 | 3` to a tuple like `[1, 2, 3]`.
*/
export type UnionToTuple<Union> = TuplifyUnion<Union>

export type IsTuple<T> = Or<[Extends<T, []>, Extends<T, [any, ...any[]]>]>

export type IsUnion<T> = Not<Extends<UnionToTuple<T>['length'], 1>>

/**
* A recursive version of `Pick` that selects properties from the left type that are present in the right type.
* The "leaf" types from `Left` are used - only the keys of `Right` are considered.
*
* @example
* ```ts
* const user = {email: 'a@b.com', name: 'John Doe', address: {street: '123 2nd St', city: 'New York', zip: '10001', state: 'NY', country: 'USA'}}
*
* type Result = DeepPickMatchingProps<typeof user, {name: unknown; address: {city: unknown}}> // {name: string, address: {city: string}}
* ```
*/
export type DeepPickMatchingProps<Left, Right> =
Left extends Record<string, unknown>
? Pick<
{[K in keyof Left]: K extends keyof Right ? DeepPickMatchingProps<Left[K], Right[K]> : never},
Extract<keyof Left, keyof Right>
>
: Left
Loading