Skip to content

Commit a37c088

Browse files
ivanbanovclaude
andauthored
feat(merge-props): generic over the consumer's props — cast-free call sites (#51)
mergeProps typed Record<string, unknown> on both ends forced every binding call site into a double cast: framework prop interfaces (no index signature) aren't assignable in, and the anonymous return can't spread onto a typed component. Make it generic (base + react/native/opentui): consumer props pass in as-is and return as the Object.assign-style intersection Props & Record<string, unknown> — assignable to the component's props, library bindings still reachable. Behavior unchanged. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 521440e commit a37c088

7 files changed

Lines changed: 91 additions & 26 deletions

File tree

.changeset/mergeprops-generics.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@dunky.dev/state-machine-utils': minor
3+
'@dunky.dev/react-state-machine': minor
4+
'@dunky.dev/native-state-machine': minor
5+
'@dunky.dev/opentui-state-machine': minor
6+
---
7+
8+
`mergeProps` is generic over the consumer's props: a framework prop type (an
9+
interface without an index signature — `PressableProps`, `ComponentProps<'div'>`)
10+
now passes in and comes back out cast-free. Behavior is unchanged; the merged
11+
bag still carries the library's bindings, typed as the consumer's props (the
12+
`Object.assign` convention), so the JSX spread stays clean.
13+
14+
```tsx
15+
// before
16+
const merged = mergeProps(props as Record<string, unknown>, bindings) as PressableProps
17+
// after
18+
const merged = mergeProps(props, bindings)
19+
```

packages/native/src/merge-props.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'
22

33
type AnyProps = Record<string, unknown>
44

5-
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
6-
const merged = baseMergeProps(consumer, library)
7-
if (!consumer) return merged
5+
export function mergeProps<Props extends object = AnyProps>(
6+
consumer: Props | undefined,
7+
library: AnyProps,
8+
): Props & AnyProps {
9+
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
10+
if (!consumer) return merged as Props & AnyProps
11+
const own = consumer as AnyProps
812

9-
if (consumer.style != null && library.style != null) {
10-
merged.style = [consumer.style, library.style]
13+
if (own.style != null && library.style != null) {
14+
merged.style = [own.style, library.style]
1115
}
1216

13-
return merged
17+
return merged as Props & AnyProps
1418
}

packages/native/tests/merge-props.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
22
import { mergeProps } from '@dunky.dev/native-state-machine'
33

44
describe('mergeProps', () => {
@@ -43,3 +43,16 @@ describe('mergeProps', () => {
4343
expect(out.className).toBe('b')
4444
})
4545
})
46+
47+
describe('mergeProps typing', () => {
48+
it('preserves a typed consumer through the style merge', () => {
49+
interface PressableLikeProps {
50+
style?: unknown
51+
onPress?: () => void
52+
}
53+
const consumer: PressableLikeProps = { style: { opacity: 1 } }
54+
const out = mergeProps(consumer, { style: { opacity: 0.5 } })
55+
expectTypeOf(out).toExtend<PressableLikeProps>()
56+
expect(out.style).toEqual([{ opacity: 1 }, { opacity: 0.5 }])
57+
})
58+
})

packages/opentui/src/merge-props.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ type AnyProps = Record<string, unknown>
44

55
// OpenTUI style is a plain object (not array-mergeable like RN), so overlapping styles spread
66
// into one object with library winning on conflicts. No className in a terminal.
7-
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
8-
const merged = baseMergeProps(consumer, library)
9-
if (!consumer) return merged
7+
export function mergeProps<Props extends object = AnyProps>(
8+
consumer: Props | undefined,
9+
library: AnyProps,
10+
): Props & AnyProps {
11+
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
12+
if (!consumer) return merged as Props & AnyProps
1013

11-
const consumerStyle = consumer.style
14+
const consumerStyle = (consumer as AnyProps).style
1215
const libraryStyle = library.style
1316
if (isStyleObject(consumerStyle) && isStyleObject(libraryStyle)) {
1417
merged.style = { ...consumerStyle, ...libraryStyle }
1518
}
1619

17-
return merged
20+
return merged as Props & AnyProps
1821
}
1922

2023
function isStyleObject(value: unknown): value is Record<string, unknown> {

packages/react/src/merge-props.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'
22

33
type AnyProps = Record<string, unknown>
44

5-
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
6-
const merged = baseMergeProps(consumer, library)
7-
if (!consumer) return merged
5+
export function mergeProps<Props extends object = AnyProps>(
6+
consumer: Props | undefined,
7+
library: AnyProps,
8+
): Props & AnyProps {
9+
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
10+
if (!consumer) return merged as Props & AnyProps
11+
const own = consumer as AnyProps
812

9-
if (consumer.style != null && library.style != null) {
10-
merged.style = [consumer.style, library.style]
13+
if (own.style != null && library.style != null) {
14+
merged.style = [own.style, library.style]
1115
}
12-
if (typeof consumer.className === 'string' && typeof library.className === 'string') {
13-
merged.className = `${consumer.className} ${library.className}`.trim()
16+
if (typeof own.className === 'string' && typeof library.className === 'string') {
17+
merged.className = `${own.className} ${library.className}`.trim()
1418
}
1519

16-
return merged
20+
return merged as Props & AnyProps
1721
}

packages/shared/utils/src/utils/merge-props.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler {
1818
}
1919
}
2020

21-
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
22-
if (!consumer) return library
23-
const out: AnyProps = { ...consumer }
21+
// Generic over the consumer's props so framework prop types (interfaces
22+
// without an index signature) pass in and come back out cast-free. The return
23+
// is the Object.assign-style intersection: assignable to the consumer's props
24+
// (the JSX spread needs no cast) while the library's bindings stay reachable.
25+
export function mergeProps<Props extends object = AnyProps>(
26+
consumer: Props | undefined,
27+
library: AnyProps,
28+
): Props & AnyProps {
29+
if (!consumer) return library as Props & AnyProps
30+
const out: AnyProps = { ...(consumer as AnyProps) }
2431

2532
for (const [key, libValue] of Object.entries(library)) {
26-
const consumerValue = consumer[key]
33+
const consumerValue = (consumer as AnyProps)[key]
2734

2835
if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
2936
out[key] = compose(consumerValue, libValue)
@@ -34,5 +41,5 @@ export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): A
3441
out[key] = libValue
3542
}
3643

37-
return out
44+
return out as Props & AnyProps
3845
}

packages/shared/utils/tests/merge-props.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
22
import { mergeProps } from '@dunky.dev/state-machine-utils'
33

44
describe('mergeProps', () => {
@@ -85,3 +85,18 @@ describe('mergeProps', () => {
8585
expect(consumer.id).toBe('a')
8686
})
8787
})
88+
89+
describe('mergeProps typing', () => {
90+
it('accepts a typed consumer interface and preserves its type', () => {
91+
// An interface without an index signature — the shape every framework's
92+
// prop types have. Must be accepted and returned as-is, cast-free.
93+
interface ButtonLikeProps {
94+
id?: string
95+
onClick?: (event: { defaultPrevented: boolean }) => void
96+
}
97+
const consumer: ButtonLikeProps = { id: 'mine' }
98+
const out = mergeProps(consumer, { role: 'button' })
99+
expectTypeOf(out).toExtend<ButtonLikeProps>()
100+
expect(out.id).toBe('mine')
101+
})
102+
})

0 commit comments

Comments
 (0)