From f59ed249da6391324a8e6d1e51cc914ee4e699a5 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Sat, 7 Oct 2023 13:28:42 -0400 Subject: [PATCH 1/4] fix(types)!: do not try to infer types of overloaded functions --- README.md | 14 ++++++++++ src/behaviors.ts | 16 +++++------- src/stubs.ts | 10 ++++---- src/types.ts | 58 +---------------------------------------- src/vitest-when.ts | 10 +++----- test/typing.test-d.ts | 60 +++++++++++++++---------------------------- 6 files changed, 50 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index f94cbd3..bc5a394 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,20 @@ expect(spy('hello')).toEqual('goodbye'); [asymmetric matchers]: https://vitest.dev/api/expect.html#expect-anything +#### Types of overloaded functions + +Due to fundamental limitations of how TypeScript handles the types of overloaded functions, `when` will always pick the _last_ overload as the type of `TFunc`. You can use the `TFunc` type argument of when to customize this if you're stubbing a different overload: + +```ts +function overloaded(): null; +function overloaded(input: number): string; +function overloaded(input?: number): string | null { + // ... +} + +when<() => null>(overloaded).calledWith().thenReturn(null); +``` + ### `.thenReturn(value: TReturn)` When the stubbing is satisfied, return `value` diff --git a/src/behaviors.ts b/src/behaviors.ts index 91ebcb9..8b2a8e8 100644 --- a/src/behaviors.ts +++ b/src/behaviors.ts @@ -1,9 +1,5 @@ import { equals } from '@vitest/expect'; -import type { - AnyFunction, - AllParameters, - ReturnTypeFromArgs, -} from './types.ts'; +import type { AnyFunction } from './types.ts'; export const ONCE = Symbol('ONCE'); @@ -11,12 +7,12 @@ export type StubValue = TValue | typeof ONCE; export interface BehaviorStack { use: ( - args: AllParameters - ) => BehaviorEntry> | undefined; + args: Parameters + ) => BehaviorEntry> | undefined; - bindArgs: >( + bindArgs: >( args: TArgs - ) => BoundBehaviorStack>; + ) => BoundBehaviorStack>; } export interface BoundBehaviorStack { @@ -43,7 +39,7 @@ export interface BehaviorOptions { export const createBehaviorStack = < TFunc extends AnyFunction >(): BehaviorStack => { - const behaviors: BehaviorEntry>[] = []; + const behaviors: BehaviorEntry>[] = []; return { use: (args) => { diff --git a/src/stubs.ts b/src/stubs.ts index d3e0298..76083e3 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -1,12 +1,12 @@ import type { Mock as Spy } from 'vitest'; import { createBehaviorStack, type BehaviorStack } from './behaviors.ts'; import { NotAMockFunctionError } from './errors.ts'; -import type { AnyFunction, AllParameters } from './types.ts'; +import type { AnyFunction } from './types.ts'; const BEHAVIORS_KEY = Symbol('behaviors'); interface WhenStubImplementation { - (...args: AllParameters): unknown; + (...args: Parameters): unknown; [BEHAVIORS_KEY]: BehaviorStack; } @@ -25,7 +25,7 @@ export const configureStub = ( const behaviors = createBehaviorStack(); - const implementation = (...args: AllParameters): unknown => { + const implementation = (...args: Parameters): unknown => { const behavior = behaviors.use(args); if (behavior?.throwError) { @@ -48,7 +48,7 @@ export const configureStub = ( const validateSpy = ( maybeSpy: unknown -): Spy, unknown> => { +): Spy, unknown> => { if ( typeof maybeSpy === 'function' && 'mockImplementation' in maybeSpy && @@ -56,7 +56,7 @@ const validateSpy = ( 'getMockImplementation' in maybeSpy && typeof maybeSpy.getMockImplementation === 'function' ) { - return maybeSpy as Spy, unknown>; + return maybeSpy as Spy, unknown>; } throw new NotAMockFunctionError(maybeSpy); diff --git a/src/types.ts b/src/types.ts index 7fd9319..b73a897 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,60 +1,4 @@ -/** - * Get function arguments and return value types. - * - * Support for overloaded functions, thanks to @Shakeskeyboarde - * https://github.com/microsoft/TypeScript/issues/14107#issuecomment-1146738780 - */ - -import type { SpyInstance } from 'vitest'; +/** Common type definitions. */ /** Any function, for use in `extends` */ export type AnyFunction = (...args: never[]) => unknown; - -/** Acceptable arguments for a function.*/ -export type AllParameters = - TFunc extends SpyInstance - ? TArgs - : Parameters>; - -/** The return type of a function, given the actual arguments used.*/ -export type ReturnTypeFromArgs< - TFunc extends AnyFunction, - TArgs extends unknown[] -> = TFunc extends SpyInstance - ? TReturn - : ExtractReturn, TArgs>; - -/** Given a functions and actual arguments used, extract the return type. */ -type ExtractReturn< - TFunc extends AnyFunction, - TArgs extends unknown[] -> = TFunc extends (...args: infer TFuncArgs) => infer TFuncReturn - ? TArgs extends TFuncArgs - ? TFuncReturn - : never - : never; - -/** Transform an overloaded function into a union of functions. */ -type ToOverloads = Exclude< - OverloadUnion<(() => never) & TFunc>, - TFunc extends () => never ? never : () => never ->; - -/** Recursively extract functions from an overload into a union. */ -type OverloadUnion = TFunc extends ( - ...args: infer TArgs -) => infer TReturn - ? TPartialOverload extends TFunc - ? never - : - | OverloadUnion< - TPartialOverload & TFunc, - TPartialOverload & - ((...args: TArgs) => TReturn) & - OverloadProps - > - | ((...args: TArgs) => TReturn) - : never; - -/** Properties attached to a function. */ -type OverloadProps = Pick; diff --git a/src/vitest-when.ts b/src/vitest-when.ts index b0acb60..016e2b9 100644 --- a/src/vitest-when.ts +++ b/src/vitest-when.ts @@ -1,18 +1,14 @@ import { configureStub } from './stubs.ts'; import type { StubValue } from './behaviors.ts'; -import type { - AnyFunction, - AllParameters, - ReturnTypeFromArgs, -} from './types.ts'; +import type { AnyFunction } from './types.ts'; export { ONCE, type StubValue } from './behaviors.ts'; export * from './errors.ts'; export interface StubWrapper { - calledWith>( + calledWith>( ...args: TArgs - ): Stub>; + ): Stub>; } export interface Stub { diff --git a/test/typing.test-d.ts b/test/typing.test-d.ts index e477bce..eb33aab 100644 --- a/test/typing.test-d.ts +++ b/test/typing.test-d.ts @@ -31,15 +31,12 @@ describe('vitest-when type signatures', () => { assertType>(stub); }); - it('should reject invalid usage of a simple function', () => { - // @ts-expect-error: args missing - subject.when(simple).calledWith(); + it('should handle a generic function', () => { + const stub = subject.when(generic).calledWith(1); - // @ts-expect-error: args wrong type - subject.when(simple).calledWith('hello'); + stub.thenReturn('hello'); - // @ts-expect-error: return wrong type - subject.when(simple).calledWith(1).thenReturn(42); + assertType>(stub); }); it('should handle an overloaded function using its last overload', () => { @@ -50,30 +47,14 @@ describe('vitest-when type signatures', () => { assertType>(stub); }); - it('should handle an overloaded function using its first overload', () => { - const stub = subject.when(overloaded).calledWith(); + it('should handle an overloaded function using an explicit type', () => { + const stub = subject.when<() => null>(overloaded).calledWith(); stub.thenReturn(null); assertType>(stub); }); - it('should handle an very overloaded function using its first overload', () => { - const stub = subject.when(veryOverloaded).calledWith(); - - stub.thenReturn(null); - - assertType>(stub); - }); - - it('should handle an overloaded function using its last overload', () => { - const stub = subject.when(veryOverloaded).calledWith(1, 2, 3, 4); - - stub.thenReturn(42); - - assertType>(stub); - }); - it('should reject invalid usage of a simple function', () => { // @ts-expect-error: args missing subject.when(simple).calledWith(); @@ -84,6 +65,17 @@ describe('vitest-when type signatures', () => { // @ts-expect-error: return wrong type subject.when(simple).calledWith(1).thenReturn(42); }); + + it('should reject invalid usage of a generic function', () => { + // @ts-expect-error: args missing + subject.when(generic).calledWith(); + + // @ts-expect-error: args wrong type + subject.when(generic).calledWith(42); + + // @ts-expect-error: return wrong type + subject.when(generic).calledWith(1).thenReturn(42); + }); }); function untyped(...args: any[]): any { @@ -94,22 +86,12 @@ function simple(input: number): string { throw new Error(`simple(${input})`); } +function generic(input: T): string { + throw new Error(`generic(${input})`); +} + function overloaded(): null; function overloaded(input: number): string; function overloaded(input?: number): string | null { throw new Error(`overloaded(${input})`); } - -function veryOverloaded(): null; -function veryOverloaded(i1: number): string; -function veryOverloaded(i1: number, i2: number): boolean; -function veryOverloaded(i1: number, i2: number, i3: number): null; -function veryOverloaded(i1: number, i2: number, i3: number, i4: number): number; -function veryOverloaded( - i1?: number, - i2?: number, - i3?: number, - i4?: number -): string | boolean | number | null { - throw new Error(`veryOverloaded(${i1}, ${i2}, ${i3}, ${i4})`); -} From 8c5909ddec82595de4fd4f167f04f8c01bc529c4 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 7 Oct 2023 13:40:14 -0400 Subject: [PATCH 2/4] fixup: docs copy --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc5a394..da54946 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ expect(spy('hello')).toEqual('goodbye'); #### Types of overloaded functions -Due to fundamental limitations of how TypeScript handles the types of overloaded functions, `when` will always pick the _last_ overload as the type of `TFunc`. You can use the `TFunc` type argument of when to customize this if you're stubbing a different overload: +Due to fundamental limitations in TypeScript, `when()` will always use the _last_ overload to infer function parameters and return types. You can use the `TFunc` type parameter of `when()` to manually select a different overload entry: ```ts function overloaded(): null; @@ -241,6 +241,12 @@ function overloaded(input?: number): string | null { // ... } +// $ts-expect-error: +// - Expected 1 argument, but got 0. +// - Argument of type 'null' is not assignable to parameter of type 'StubValue'. +subject.when(overloaded).calledWith().thenReturn(null); + +// all good! when<() => null>(overloaded).calledWith().thenReturn(null); ``` From 71fdca83e79947e8a6a7584b7a0b3b3138780278 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 7 Oct 2023 13:47:20 -0400 Subject: [PATCH 3/4] fixup: docs copy --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index da54946..0afe3fd 100644 --- a/README.md +++ b/README.md @@ -241,12 +241,13 @@ function overloaded(input?: number): string | null { // ... } -// $ts-expect-error: -// - Expected 1 argument, but got 0. -// - Argument of type 'null' is not assignable to parameter of type 'StubValue'. +// Last entry: all good! +when(overloaded).calledWith(42).thenReturn('hello'); + +// $ts-expect-error: first entry subject.when(overloaded).calledWith().thenReturn(null); -// all good! +// Manually specified: all good! when<() => null>(overloaded).calledWith().thenReturn(null); ``` From e7f7888673d919ca67b28c07ea33d9a699845a8f Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 7 Oct 2023 13:47:50 -0400 Subject: [PATCH 4/4] fixup: docs copy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0afe3fd..6f62453 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ function overloaded(input?: number): string | null { when(overloaded).calledWith(42).thenReturn('hello'); // $ts-expect-error: first entry -subject.when(overloaded).calledWith().thenReturn(null); +when(overloaded).calledWith().thenReturn(null); // Manually specified: all good! when<() => null>(overloaded).calledWith().thenReturn(null);