Skip to content

Commit f76c5e1

Browse files
MyshkouskiAlexei Myshkouskizernoniaclaude
authored
feat: enhance type inference for useEmitAsProps and useForwardPropsEmits (#2643)
* feat(core): add typed return values for useEmitAsProps The useEmitAsProps function now properly types the returned props based on the emit function signature, including support for function overloads. This improves TypeScript type inference and provides better IDE autocomplete for event handlers. - Added generic type parameter Fn for the emit function type - Added type utilities for handling function overloads - Result type now accurately reflects the event handler signatures * feat(core): enhance type inference for useForwardPropsEmits Exported WithOptionalBooleans type utility and added function overloads to properly infer ComputedRef return types based on emit parameter. The overloads accurately reflect whether emits are converted to props. * fix(core): correct CamelCase type conversion for kebab-case strings * fix(core): resolve ToEmit type inference for overloaded functions The previous implementation directly pattern-matched on function signatures, which only works for non-overloaded functions. This change utilizes OverloadSignatureTuple to correctly extract and match overload signatures against the expected emit name, enabling proper type inference for functions with multiple overloads. * docs(core): update ExtendSignature @ts-expect-error explanation Expand the comment to clarify that ExtendSignature intentionally extends generic type T to inherit all overloads for the recursive overload walking algorithm, and warn against removing the suppression as it would break the recursion/bottoming-out logic. * refactor(core): rename UnionToOptional to MergeUnion utility type Rename the UnionToOptional type to MergeUnion to better reflect its purpose of converting a union of function signatures into a single merged object type with optional properties. The implementation remains unchanged, only the type name is updated for improved clarity and maintainability. * refactor(core): replace Function with AnyFn for improved type inference Replace the generic `Function` type with `AnyFn` from `@vueuse/shared` across `useEmitAsProps` and `useForwardPropsEmits` utilities. This change improves type inference and type safety by using a more specific function type constraint instead of the overly broad `Function` type, which provides better IDE autocompletion and catches more type errors at compile time. The runtime behavior remains unchanged. * refactor(core): simplify useEmitAsProps type definitions - add package-private overload types from @vue/shared package - simplify utility types * fix(core): export EmitAsProps type from shared index * fix(core): improve camelCase type conversion to handle all word characters * test(core): add type tests for useEmitAsProps and useForwardPropsEmits Compile-time assertions (validated by vue-tsc via tsconfig.check.json) that lock in the new emit type inference: overloaded, single, and loose emit signatures, plus the props+emit merge of useForwardPropsEmits. The file is type-checked by the existing build job but not run by Vitest or bundled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Alexei Myshkouski <am@samplify.org> Co-authored-by: zernonia <zernonia@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4549d71 commit f76c5e1

5 files changed

Lines changed: 179 additions & 8 deletions

File tree

packages/core/src/shared/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export { useBodyScrollLock } from './useBodyScrollLock'
1717
export { useComposing } from './useComposing'
1818
export { type Formatter, useDateFormatter } from './useDateFormatter'
1919
export { useDirection } from './useDirection'
20-
export { useEmitAsProps } from './useEmitAsProps'
20+
export { type EmitAsProps, useEmitAsProps } from './useEmitAsProps'
2121
export { useFilter } from './useFilter'
2222
export { useFocusGuards } from './useFocusGuards'
2323
export { useFormControl } from './useFormControl'
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Compile-time type tests for `useEmitAsProps` and `useForwardPropsEmits`.
3+
*
4+
* These assertions are validated by `vue-tsc` (the `type-check` / `build` step):
5+
* `tsconfig.check.json` includes `src/**` and only excludes `*.test.ts`, so this
6+
* `*.test-d.ts` file is type-checked but is neither executed by Vitest (its glob
7+
* is `*.test.{ts,js}`) nor bundled (nothing imports it). A regression in the emit
8+
* type inference therefore fails the build.
9+
*/
10+
import type { ComputedRef } from 'vue'
11+
import type { EmitAsProps } from './useEmitAsProps'
12+
import { useEmitAsProps } from './useEmitAsProps'
13+
import { useForwardPropsEmits } from './useForwardPropsEmits'
14+
15+
// Assertion helpers
16+
type Expect<T extends true> = T
17+
type Equal<A, B>
18+
= (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
19+
20+
// Fixtures mirroring the emit shapes Vue produces from `defineEmits`
21+
type OverloadedEmit = {
22+
(e: 'update:modelValue', value: string): void
23+
(e: 'change', value: number, extra: boolean): void
24+
}
25+
declare const overloadedEmit: OverloadedEmit
26+
27+
type SingleEmit = (e: 'close', reason: string) => void
28+
declare const singleEmit: SingleEmit
29+
30+
// The loose signature used internally by some components must stay assignable
31+
type LooseEmit = (name: string, ...args: any[]) => void
32+
declare const looseEmit: LooseEmit
33+
34+
// useEmitAsProps — overloaded emit maps each event to its `onXxx` handler prop
35+
{
36+
const result = useEmitAsProps(overloadedEmit)
37+
type Result = typeof result
38+
type _keys = Expect<Equal<keyof Result, 'onUpdate:modelValue' | 'onChange'>>
39+
type _update = Expect<Equal<Parameters<NonNullable<Result['onUpdate:modelValue']>>, [value: string]>>
40+
type _change = Expect<Equal<Parameters<NonNullable<Result['onChange']>>, [value: number, extra: boolean]>>
41+
}
42+
43+
// useEmitAsProps — single (non-overloaded) emit
44+
{
45+
const result = useEmitAsProps(singleEmit)
46+
type Result = typeof result
47+
type _keys = Expect<Equal<keyof Result, 'onClose'>>
48+
type _args = Expect<Equal<Parameters<NonNullable<Result['onClose']>>, [reason: string]>>
49+
}
50+
51+
// useEmitAsProps — loose signature stays accepted (backwards compatibility)
52+
{
53+
const result = useEmitAsProps(looseEmit)
54+
void result
55+
}
56+
57+
// EmitAsProps utility — emit signature to handler props
58+
type _emitAsProps = Expect<Equal<keyof EmitAsProps<OverloadedEmit>, 'onUpdate:modelValue' | 'onChange'>>
59+
60+
// useForwardPropsEmits — fixtures
61+
interface DemoProps { foo: string, bar: boolean }
62+
declare const props: DemoProps
63+
64+
// With emit: forwarded props AND emit-as-props are both present and typed
65+
{
66+
const forwarded = useForwardPropsEmits(props, overloadedEmit)
67+
type Value = typeof forwarded extends ComputedRef<infer V> ? V : never
68+
type _prop = Expect<Equal<Value['foo'], string>>
69+
type _emit = Expect<Equal<Parameters<NonNullable<Value['onChange']>>, [value: number, extra: boolean]>>
70+
}
71+
72+
// Without emit: only forwarded props, no `on*` handler keys leak in
73+
{
74+
const forwarded = useForwardPropsEmits(props)
75+
type Value = typeof forwarded extends ComputedRef<infer V> ? V : never
76+
type _noHandlers = Expect<Equal<Extract<keyof Value, `on${string}`>, never>>
77+
}

packages/core/src/shared/useEmitAsProps.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1+
import type { AnyFn } from '@vueuse/shared'
12
import { camelize, getCurrentInstance, toHandlerKey } from 'vue'
23

34
// Vue doesn't have emits forwarding, in order to bind the emits we have to convert events into `onXXX` handlers
45
// issue: https://github.com/vuejs/core/issues/5917
56
/**
67
* The `useEmitAsProps` function is a TypeScript utility that converts emitted events into props for a
78
* Vue component.
9+
*
10+
* @template Name - The event name string union type.
11+
* @template Fn - The emit function type.
12+
*
813
* @param emit - The `emit` parameter is a function that is used to emit events from a component. It
14+
*
915
* takes two parameters: `name` which is the name of the event to be emitted, and `...args` which are
1016
* the arguments to be passed along with the event.
1117
* @returns The function `useEmitAsProps` returns an object that maps event names to functions that
1218
* call the `emit` function with the corresponding event name and arguments.
1319
*/
14-
export function useEmitAsProps<Name extends string>(
15-
emit: (name: Name, ...args: any[]) => void,
16-
) {
20+
export function useEmitAsProps<Name extends string, Fn extends AnyFn = AnyFn>(emit: Emit<Name, Fn>) {
1721
const vm = getCurrentInstance()
1822

1923
const events = vm?.type.emits as Name[]
@@ -28,5 +32,80 @@ export function useEmitAsProps<Name extends string>(
2832
events?.forEach((ev) => {
2933
result[toHandlerKey(camelize(ev))] = (...arg: any) => emit(ev, ...arg)
3034
})
31-
return result
35+
36+
return result as EmitAsProps<Fn>
3237
}
38+
39+
export type EmitAsProps<T extends AnyFn> = Expand<Partial<MergeUnion<EmitUnion<OverloadUnion<T>>>>>
40+
41+
export type Emit<Name extends string, Fn extends AnyFn> = IsEmit<Name, Fn> extends true ? Fn : never
42+
43+
type IsEmit<Name extends string, Fn extends AnyFn>
44+
= OverloadUnion<Fn> extends infer T extends AnyFn
45+
? Parameters<T> extends [name: Name, ...args: any[]]
46+
? [ReturnType<T>] extends [void]
47+
? true
48+
: false
49+
: false
50+
: false
51+
52+
type Expand<T> = {
53+
[K in keyof T]: T[K]
54+
} & {}
55+
56+
type MergeUnion<T> = {
57+
[K in T extends any ? keyof T : never]: T extends { [P in K]: any } ? T[K] : never;
58+
}
59+
60+
type EmitUnion<Fn extends AnyFn>
61+
= Fn extends (name: infer Name extends string, ...args: infer Args) => infer Return
62+
? { [K in HandlerKey<Name>]: (...args: Args) => Return }
63+
: unknown
64+
65+
// Types for prop keys
66+
67+
type WordChar
68+
= | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
69+
| 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
70+
| 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'
71+
| 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z'
72+
| '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
73+
| '_'
74+
75+
/**
76+
* Type-safe converter for camelize(string) from Vue package
77+
*/
78+
export type Camelize<S extends string>
79+
= S extends `${infer Prefix}-${infer Rest}`
80+
? Rest extends `${infer Char}${infer After}`
81+
? Char extends WordChar
82+
? `${Camelize<Prefix>}${Uppercase<Char>}${Camelize<After>}`
83+
: `${Camelize<Prefix>}-${Camelize<Rest>}`
84+
: S
85+
: S
86+
87+
type HandlerKey<Name extends string> = Camelize<`on-${Name}`>
88+
89+
// Package-private overload types from @vue/shared
90+
91+
type OverloadProps<TOverload> = Pick<TOverload, keyof TOverload>
92+
93+
type OverloadUnionRecursive<
94+
TOverload,
95+
TPartialOverload = unknown,
96+
> = TOverload extends (...args: infer TArgs) => infer TReturn
97+
? TPartialOverload extends TOverload
98+
? never
99+
: | OverloadUnionRecursive<
100+
TPartialOverload & TOverload,
101+
TPartialOverload
102+
& ((...args: TArgs) => TReturn)
103+
& OverloadProps<TOverload>
104+
>
105+
| ((...args: TArgs) => TReturn)
106+
: never
107+
108+
type OverloadUnion<TOverload extends (...args: any[]) => any> = Exclude<
109+
OverloadUnionRecursive<(() => never) & TOverload>,
110+
TOverload extends () => never ? never : () => never
111+
>

packages/core/src/shared/useForwardProps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface PropOptions {
1212
* in the `defineProps` return type. Since `useForwardProps` only returns props that were
1313
* explicitly assigned, boolean-typed props should remain optional in the return type.
1414
*/
15-
type WithOptionalBooleans<T> = {
15+
export type WithOptionalBooleans<T> = {
1616
[K in keyof T as [T[K]] extends [boolean] ? K : never]?: T[K]
1717
} & {
1818
[K in keyof T as [T[K]] extends [boolean] ? never : K]: T[K]

packages/core/src/shared/useForwardPropsEmits.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { MaybeRefOrGetter } from 'vue'
1+
import type { AnyFn } from '@vueuse/shared'
2+
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
3+
import type { Emit, EmitAsProps } from './useEmitAsProps'
4+
import type { WithOptionalBooleans } from './useForwardProps'
25
import { computed } from 'vue'
36
import { useEmitAsProps } from './useEmitAsProps'
47
import { useForwardProps } from './useForwardProps'
@@ -15,7 +18,19 @@ import { useForwardProps } from './useForwardProps'
1518
* @returns a computed property that combines the parsed
1619
* props and emits as props.
1720
*/
18-
export function useForwardPropsEmits<T extends Record<string, any>, Name extends string>(props: MaybeRefOrGetter<T>, emit?: (name: Name, ...args: any[]) => void) {
21+
export function useForwardPropsEmits<
22+
T extends Record<string, any>,
23+
>(props: MaybeRefOrGetter<T>): ComputedRef<WithOptionalBooleans<T>>
24+
export function useForwardPropsEmits<
25+
T extends Record<string, any>,
26+
Name extends string,
27+
Fn extends AnyFn = AnyFn,
28+
>(props: MaybeRefOrGetter<T>, emit: Emit<Name, Fn>): ComputedRef<WithOptionalBooleans<T> & EmitAsProps<Fn>>
29+
export function useForwardPropsEmits<
30+
T extends Record<string, any>,
31+
Name extends string,
32+
Fn extends AnyFn = AnyFn,
33+
>(props: MaybeRefOrGetter<T>, emit?: Emit<Name, Fn>) {
1934
const parsedProps = useForwardProps(props)
2035
const emitsAsProps = emit ? useEmitAsProps(emit) : {}
2136

0 commit comments

Comments
 (0)