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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
57 changes: 38 additions & 19 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,31 +89,51 @@ 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:
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 - 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
expectTypeOf<Fruit>().toMatchTypeOf<Apple>()
expectTypeOf<Fruit>().toExtend<Apple>()

// @ts-expect-error
expectTypeOf<Apple>().toEqualTypeOf<Fruit>()
Expand All @@ -122,7 +142,8 @@ expectTypeOf<Apple>().toEqualTypeOf<Fruit>()
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 +152,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 +251,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 +599,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 +695,6 @@ class B {
foo() {
// @ts-expect-error
expectTypeOf(this).toEqualTypeOf(this)
// @ts-expect-error
expectTypeOf(this).toMatchTypeOf(this)
}
}

Expand Down Expand Up @@ -710,9 +729,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
50 changes: 49 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
OverloadReturnTypes,
OverloadsNarrowedByParameters,
} from './overloads'
import type {AValue, Extends, MismatchArgs, StrictEqualUsingTSInternalIdenticalToOperator} from './utils'
import type {AValue, 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 +36,28 @@ export * from './utils' // backcompat, consider removing in next major version
* {@linkcode expectTypeOf()} utility.
*/
export interface PositiveExpectTypeOf<Actual> extends BaseExpectTypeOf<Actual, {positive: true; branded: false}> {
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<
Pick<Actual, keyof Actual & keyof Expected>,
Expected
> extends true
? unknown
: MismatchInfo<Pick<Actual, keyof Actual & keyof Expected>, Expected>,
>(
...MISMATCH: MismatchArgs<
StrictEqualUsingTSInternalIdenticalToOperator<Pick<Actual, keyof Actual & keyof 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 +140,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 +181,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 +302,15 @@ 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}> {
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 +979,8 @@ export const expectTypeOf: _ExpectTypeOf = <Actual>(
toMatchTypeOf: fn,
toEqualTypeOf: fn,
toBeConstructibleWith: fn,
toMatchObjectType: fn,
toExtend: fn,
toBeCallableWith: expectTypeOf,
extract: expectTypeOf,
exclude: expectTypeOf,
Expand Down
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,7 @@ 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>>
59 changes: 33 additions & 26 deletions test/__snapshots__/errors.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ exports[`usage.test.ts 1`] = `

999 expectTypeOf({a: 1, b: 1}).toEqualTypeOf<{a: number}>()
~~~~~~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type '{ b: number; }' does not satisfy the constraint '{ a: "Expected: never, Actual: number"; b: "Expected: number, Actual: never"; }'.
Property 'a' is missing in type '{ b: number; }' but required in type '{ a: "Expected: never, Actual: number"; b: "Expected: number, Actual: never"; }'.

999 expectTypeOf({a: 1}).toExtend<{b: number}>()
~~~~~~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type '{ a: number; b: number; }' does not satisfy the constraint '{ a: number; b: "Expected: number, Actual: never"; }'.
Types of property 'b' are incompatible.
Type 'number' is not assignable to type '"Expected: number, Actual: never"'.
Expand All @@ -16,29 +21,46 @@ test/usage.test.ts:999:999 - error TS2344: Type '{ a: number; b: number; }' does
Types of property 'b' are incompatible.
Type 'number' is not assignable to type '"Expected: number, Actual: never"'.

999 expectTypeOf({a: 1}).toMatchTypeOf<{a: number; b: number}>()
~~~~~~~~~~~~~~~~~~~~~~
999 expectTypeOf({a: 1}).toMatchObjectType<{a: number; b: number}>()
~~~~~~~~~~~~~~~~~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type '{ a: number; b: number; }' does not satisfy the constraint '{ a: number; b: "Expected: number, Actual: never"; }'.
Types of property 'b' are incompatible.
Type 'number' is not assignable to type '"Expected: number, Actual: never"'.

999 expectTypeOf({a: 1}).toExtend<{a: number; b: number}>()
~~~~~~~~~~~~~~~~~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'Fruit' does not satisfy the constraint '{ type: "Fruit"; edible: "Expected: boolean, Actual: never"; }'.
Types of property 'edible' are incompatible.
Type 'boolean' is not assignable to type '"Expected: boolean, Actual: never"'.

999 expectTypeOf<Apple>().toMatchObjectType<Fruit>()
~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'Apple' does not satisfy the constraint '{ name: "Expected: literal string: Apple, Actual: never"; type: "Fruit"; edible: "Expected: literal boolean: true, Actual: literal boolean: false"; }'.
Types of property 'name' are incompatible.
Type '"Apple"' is not assignable to type '"Expected: literal string: Apple, Actual: never"'.

999 expectTypeOf<Fruit>().toMatchTypeOf<Apple>()
~~~~~
999 expectTypeOf<Fruit>().toExtend<Apple>()
~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'Fruit' does not satisfy the constraint '{ name: "Expected: never, Actual: literal string: Apple"; type: "Fruit"; edible: "Expected: boolean, Actual: never"; }'.
Property 'name' is missing in type 'Fruit' but required in type '{ name: "Expected: never, Actual: literal string: Apple"; type: "Fruit"; edible: "Expected: boolean, Actual: never"; }'.

999 expectTypeOf<Apple>().toEqualTypeOf<Fruit>()
~~~~~
test/usage.test.ts:999:999 - error TS2554: Expected 0 arguments, but got 1.
test/usage.test.ts:999:999 - error TS2344: Type '{ b: 1; }' does not satisfy the constraint '{ a: "Expected: never, Actual: number"; b: "Expected: literal number: 1, Actual: never"; }'.
Property 'a' is missing in type '{ b: 1; }' but required in type '{ a: "Expected: never, Actual: number"; b: "Expected: literal number: 1, Actual: never"; }'.

999 expectTypeOf({a: 1}).toExtend<{b: 1}>()
~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type '{ b: 1; }' does not satisfy the constraint '"Expected: ..., Actual: boolean"'.

999 expectTypeOf({a: 1}).toMatchTypeOf({b: 1})
~~~~~~
999 expectTypeOf({a: 1}).toMatchObjectType<{b: 1}>()
~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'Apple' does not satisfy the constraint '{ name: "Expected: literal string: Apple, Actual: never"; type: "Fruit"; edible: "Expected: literal boolean: true, Actual: literal boolean: false"; }'.
Types of property 'name' are incompatible.
Type '"Apple"' is not assignable to type '"Expected: literal string: Apple, Actual: never"'.

999 expectTypeOf<Fruit>().toMatchTypeOf<Apple>()
~~~~~
999 expectTypeOf<Fruit>().toExtend<Apple>()
~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'Fruit' does not satisfy the constraint '{ name: "Expected: never, Actual: literal string: Apple"; type: "Fruit"; edible: "Expected: boolean, Actual: never"; }'.
Property 'name' is missing in type 'Fruit' but required in type '{ name: "Expected: never, Actual: literal string: Apple"; type: "Fruit"; edible: "Expected: boolean, Actual: never"; }'.

Expand Down Expand Up @@ -107,8 +129,8 @@ test/usage.test.ts:999:999 - error TS2349: This expression is not callable.
~~~~~~~~~~
test/usage.test.ts:999:999 - error TS2344: Type 'number' does not satisfy the constraint '"Expected: number, Actual: string"'.

999 expectTypeOf<string | number>().toMatchTypeOf<number>()
~~~~~~
999 expectTypeOf<string | number>().toExtend<number>()
~~~~~~
test/usage.test.ts:999:999 - error TS2345: Argument of type '"xxl"' is not assignable to parameter of type '"xs" | "sm" | "md"'.

999 expectTypeOf<ResponsiveProp<number>>().exclude<number | number[]>().toHaveProperty('xxl')
Expand Down Expand Up @@ -284,20 +306,5 @@ test/usage.test.ts:999:999 - error TS2769: No overload matches this call.
Argument of type '[this]' is not assignable to parameter of type 'MismatchArgs<StrictEqualUsingTSInternalIdenticalToOperator<this, StrictEqualUsingTSInternalIdenticalToOperator<this, unknown> extends true ? unknown : MismatchInfo<this, unknown>>, true>'.

999 expectTypeOf(this).toEqualTypeOf(this)
~~~~

test/usage.test.ts:999:999 - error TS2769: No overload matches this call.
Overload 1 of 2, '(value: (Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & AValue, ...MISMATCH: MismatchArgs<Extends<this, Extends<this, this> extends true ? unknown : MismatchInfo<...>>, true>): true', gave the following error.
Argument of type 'this' is not assignable to parameter of type '(Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & AValue'.
Type 'B' is not assignable to type '(Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & AValue'.
Type 'B' is not assignable to type '(Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & { [avalue]?: undefined; }'.
Type 'this' is not assignable to type '(Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & { [avalue]?: undefined; }'.
Type 'B' is not assignable to type '(Extends<this, this> extends true ? unknown : MismatchInfo<this, this>) & { [avalue]?: undefined; }'.
Type 'B' is not assignable to type 'Extends<this, this> extends true ? unknown : MismatchInfo<this, this>'.
Type 'this' is not assignable to type 'Extends<this, this> extends true ? unknown : MismatchInfo<this, this>'.
Type 'B' is not assignable to type 'Extends<this, this> extends true ? unknown : MismatchInfo<this, this>'.
Argument of type '[this]' is not assignable to parameter of type 'MismatchArgs<Extends<this, Extends<this, unknown> extends true ? unknown : MismatchInfo<this, unknown>>, true>'.

999 expectTypeOf(this).toMatchTypeOf(this)
~~~~"
`;
11 changes: 11 additions & 0 deletions test/deprecations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import {test, expect} from 'vitest'
import {tsFileErrors} from './ts-output'

test.each(['usage.test.ts', 'types.test.ts'])('%s: toMatchTypeOf matches toExtend behaviour', file => {
const filepath = path.join(__dirname, file)
const content = fs.readFileSync(filepath, 'utf8')
const updated = content.replaceAll('.toExtend', '.toMatchTypeOf')
expect(tsFileErrors({filepath: path.join(filepath), content: updated})).toBe('')
})
Loading