diff --git a/.changeset/mergeprops-generics.md b/.changeset/mergeprops-generics.md new file mode 100644 index 0000000..01c00a8 --- /dev/null +++ b/.changeset/mergeprops-generics.md @@ -0,0 +1,19 @@ +--- +'@dunky.dev/state-machine-utils': minor +'@dunky.dev/react-state-machine': minor +'@dunky.dev/native-state-machine': minor +'@dunky.dev/opentui-state-machine': minor +--- + +`mergeProps` is generic over the consumer's props: a framework prop type (an +interface without an index signature — `PressableProps`, `ComponentProps<'div'>`) +now passes in and comes back out cast-free. Behavior is unchanged; the merged +bag still carries the library's bindings, typed as the consumer's props (the +`Object.assign` convention), so the JSX spread stays clean. + +```tsx +// before +const merged = mergeProps(props as Record, bindings) as PressableProps +// after +const merged = mergeProps(props, bindings) +``` diff --git a/packages/native/src/merge-props.ts b/packages/native/src/merge-props.ts index 605113f..cb0040c 100644 --- a/packages/native/src/merge-props.ts +++ b/packages/native/src/merge-props.ts @@ -2,13 +2,17 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' type AnyProps = Record -export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { - const merged = baseMergeProps(consumer, library) - if (!consumer) return merged +export function mergeProps( + consumer: Props | undefined, + library: AnyProps, +): Props & AnyProps { + const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library) + if (!consumer) return merged as Props & AnyProps + const own = consumer as AnyProps - if (consumer.style != null && library.style != null) { - merged.style = [consumer.style, library.style] + if (own.style != null && library.style != null) { + merged.style = [own.style, library.style] } - return merged + return merged as Props & AnyProps } diff --git a/packages/native/tests/merge-props.test.ts b/packages/native/tests/merge-props.test.ts index 255aa96..b51b044 100644 --- a/packages/native/tests/merge-props.test.ts +++ b/packages/native/tests/merge-props.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { mergeProps } from '@dunky.dev/native-state-machine' describe('mergeProps', () => { @@ -43,3 +43,16 @@ describe('mergeProps', () => { expect(out.className).toBe('b') }) }) + +describe('mergeProps typing', () => { + it('preserves a typed consumer through the style merge', () => { + interface PressableLikeProps { + style?: unknown + onPress?: () => void + } + const consumer: PressableLikeProps = { style: { opacity: 1 } } + const out = mergeProps(consumer, { style: { opacity: 0.5 } }) + expectTypeOf(out).toExtend() + expect(out.style).toEqual([{ opacity: 1 }, { opacity: 0.5 }]) + }) +}) diff --git a/packages/opentui/src/merge-props.ts b/packages/opentui/src/merge-props.ts index 022ffab..9dd97c6 100644 --- a/packages/opentui/src/merge-props.ts +++ b/packages/opentui/src/merge-props.ts @@ -4,17 +4,20 @@ type AnyProps = Record // OpenTUI style is a plain object (not array-mergeable like RN), so overlapping styles spread // into one object with library winning on conflicts. No className in a terminal. -export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { - const merged = baseMergeProps(consumer, library) - if (!consumer) return merged +export function mergeProps( + consumer: Props | undefined, + library: AnyProps, +): Props & AnyProps { + const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library) + if (!consumer) return merged as Props & AnyProps - const consumerStyle = consumer.style + const consumerStyle = (consumer as AnyProps).style const libraryStyle = library.style if (isStyleObject(consumerStyle) && isStyleObject(libraryStyle)) { merged.style = { ...consumerStyle, ...libraryStyle } } - return merged + return merged as Props & AnyProps } function isStyleObject(value: unknown): value is Record { diff --git a/packages/react/src/merge-props.ts b/packages/react/src/merge-props.ts index 26d87ce..8634d3a 100644 --- a/packages/react/src/merge-props.ts +++ b/packages/react/src/merge-props.ts @@ -2,16 +2,20 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils' type AnyProps = Record -export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { - const merged = baseMergeProps(consumer, library) - if (!consumer) return merged +export function mergeProps( + consumer: Props | undefined, + library: AnyProps, +): Props & AnyProps { + const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library) + if (!consumer) return merged as Props & AnyProps + const own = consumer as AnyProps - if (consumer.style != null && library.style != null) { - merged.style = [consumer.style, library.style] + if (own.style != null && library.style != null) { + merged.style = [own.style, library.style] } - if (typeof consumer.className === 'string' && typeof library.className === 'string') { - merged.className = `${consumer.className} ${library.className}`.trim() + if (typeof own.className === 'string' && typeof library.className === 'string') { + merged.className = `${own.className} ${library.className}`.trim() } - return merged + return merged as Props & AnyProps } diff --git a/packages/shared/utils/src/utils/merge-props.ts b/packages/shared/utils/src/utils/merge-props.ts index bb2075c..7de8550 100644 --- a/packages/shared/utils/src/utils/merge-props.ts +++ b/packages/shared/utils/src/utils/merge-props.ts @@ -18,12 +18,19 @@ function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler { } } -export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps { - if (!consumer) return library - const out: AnyProps = { ...consumer } +// Generic over the consumer's props so framework prop types (interfaces +// without an index signature) pass in and come back out cast-free. The return +// is the Object.assign-style intersection: assignable to the consumer's props +// (the JSX spread needs no cast) while the library's bindings stay reachable. +export function mergeProps( + consumer: Props | undefined, + library: AnyProps, +): Props & AnyProps { + if (!consumer) return library as Props & AnyProps + const out: AnyProps = { ...(consumer as AnyProps) } for (const [key, libValue] of Object.entries(library)) { - const consumerValue = consumer[key] + const consumerValue = (consumer as AnyProps)[key] if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) { out[key] = compose(consumerValue, libValue) @@ -34,5 +41,5 @@ export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): A out[key] = libValue } - return out + return out as Props & AnyProps } diff --git a/packages/shared/utils/tests/merge-props.test.ts b/packages/shared/utils/tests/merge-props.test.ts index e05f96e..6ca5008 100644 --- a/packages/shared/utils/tests/merge-props.test.ts +++ b/packages/shared/utils/tests/merge-props.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { mergeProps } from '@dunky.dev/state-machine-utils' describe('mergeProps', () => { @@ -85,3 +85,18 @@ describe('mergeProps', () => { expect(consumer.id).toBe('a') }) }) + +describe('mergeProps typing', () => { + it('accepts a typed consumer interface and preserves its type', () => { + // An interface without an index signature — the shape every framework's + // prop types have. Must be accepted and returned as-is, cast-free. + interface ButtonLikeProps { + id?: string + onClick?: (event: { defaultPrevented: boolean }) => void + } + const consumer: ButtonLikeProps = { id: 'mine' } + const out = mergeProps(consumer, { role: 'button' }) + expectTypeOf(out).toExtend() + expect(out.id).toBe('mine') + }) +})