From d1b17ef6cea3649360ef54114404cf5bdac45364 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Sat, 7 Oct 2023 14:14:27 -0400 Subject: [PATCH] fix(types)!: do not try to infer types of overloaded functions (#2) BREAKING CHANGE: overloaded function may now require explicit type annotations --- README.md | 21 +++++++++++++++ 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, 57 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index f94cbd3..6f62453 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,27 @@ expect(spy('hello')).toEqual('goodbye'); [asymmetric matchers]: https://vitest.dev/api/expect.html#expect-anything +#### Types of overloaded functions + +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; +function overloaded(input: number): string; +function overloaded(input?: number): string | null { + // ... +} + +// Last entry: all good! +when(overloaded).calledWith(42).thenReturn('hello'); + +// $ts-expect-error: first entry +when(overloaded).calledWith().thenReturn(null); + +// Manually specified: all good! +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})`); -}