Skip to content

Commit 4c8b159

Browse files
Fix final props type inference (#87)
* Add type test for current behavoir (keys from bind excluded from final props) * Update test to match desired behavior (keys from bind are optional in final props) * Update variant type tests to match desired behavior * Update types to match desired behavior * Add more type tests * Use Show from effector to enforce better types in IDE
1 parent 60f5c02 commit 4c8b159

File tree

3 files changed

+110
-5
lines changed

3 files changed

+110
-5
lines changed

public-types/reflect.d.ts

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/consistent-type-definitions */
2-
import type { EventCallable, Store } from 'effector';
2+
import type { EventCallable, Show, Store } from 'effector';
33
import type { useUnit } from 'effector-react';
44
import type { ComponentType, FC, PropsWithChildren, ReactHTML } from 'react';
55

@@ -12,6 +12,12 @@ type Hooks = {
1212
unmounted?: EventCallable<void> | (() => unknown);
1313
};
1414

15+
/**
16+
* `bind` object type:
17+
* prop key -> store (unwrapped to reactive subscription) or any other value (used as is)
18+
*
19+
* Also handles some edge-cases like enforcing type inference for inlined callbacks
20+
*/
1521
type BindFromProps<Props> = {
1622
[K in keyof Props]?: K extends UnbindableProps
1723
? never
@@ -25,6 +31,18 @@ type BindFromProps<Props> = {
2531
: Store<Props[K]> | Props[K];
2632
};
2733

34+
/**
35+
* Computes final props type based on Props of the view component and Bind object.
36+
*
37+
* Props that are "taken" by Bind object are made **optional** in the final type,
38+
* so it is possible to owerrite them in the component usage anyway
39+
*/
40+
type FinalProps<Props, Bind extends BindFromProps<Props>> = Show<
41+
Omit<Props, keyof Bind> & {
42+
[K in Extract<keyof Bind, keyof Props>]?: Props[K];
43+
}
44+
>;
45+
2846
// relfect types
2947
/**
3048
* Operator that creates a component, which props are reactively bound to a store or statically - to any other value.
@@ -49,7 +67,7 @@ export function reflect<Props, Bind extends BindFromProps<Props>>(config: {
4967
* This configuration is passed directly to `useUnit`'s hook second argument.
5068
*/
5169
useUnitConfig?: UseUnitConfig;
52-
}): FC<Omit<Props, keyof Bind>>;
70+
}): FC<FinalProps<Props, Bind>>;
5371

5472
// Note: FC is used as a return type, because tests on a real Next.js project showed,
5573
// that if theoretically better option like (props: ...) => React.ReactNode is used,
@@ -83,7 +101,7 @@ export function createReflect<Props, Bind extends BindFromProps<Props>>(
83101
*/
84102
useUnitConfig?: UseUnitConfig;
85103
},
86-
) => FC<Omit<Props, keyof Bind>>;
104+
) => FC<FinalProps<Props, Bind>>;
87105

88106
// list types
89107
type PropsifyBind<Bind> = {
@@ -199,7 +217,7 @@ export function variant<
199217
*/
200218
useUnitConfig?: UseUnitConfig;
201219
},
202-
): FC<Omit<Props, keyof Bind>>;
220+
): FC<FinalProps<Props, Bind>>;
203221

204222
// fromTag types
205223
type GetProps<HtmlTag extends keyof ReactHTML> = Exclude<

type-tests/types-reflect.tsx

+88
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,35 @@ import { expectType } from 'tsd';
7373
expectType<React.FC>(ReflectedInput);
7474
}
7575

76+
// reflect should not allow wrong props in final types
77+
{
78+
const Input: React.FC<{
79+
value: string;
80+
onChange: (newValue: string) => void;
81+
color: 'red';
82+
}> = () => null;
83+
const $value = createStore<string>('');
84+
const changed = createEvent<string>();
85+
86+
const ReflectedInput = reflect({
87+
view: Input,
88+
bind: {
89+
value: $value,
90+
onChange: changed,
91+
},
92+
});
93+
94+
const App: React.FC = () => {
95+
return (
96+
<ReflectedInput
97+
// @ts-expect-error
98+
color="blue"
99+
/>
100+
);
101+
};
102+
expectType<React.FC>(App);
103+
}
104+
76105
// reflect should allow not-to pass required props - as they can be added later in react
77106
{
78107
const Input: React.FC<{
@@ -104,6 +133,65 @@ import { expectType } from 'tsd';
104133
expectType<React.FC>(AppFixed);
105134
}
106135

136+
// reflect should make "binded" props optional - so it is allowed to overwrite them in react anyway
137+
{
138+
const Input: React.FC<{
139+
value: string;
140+
onChange: (newValue: string) => void;
141+
color: 'red';
142+
}> = () => null;
143+
const $value = createStore<string>('');
144+
const changed = createEvent<string>();
145+
146+
const ReflectedInput = reflect({
147+
view: Input,
148+
bind: {
149+
value: $value,
150+
onChange: changed,
151+
},
152+
});
153+
154+
const App: React.FC = () => {
155+
return <ReflectedInput value="kek" color="red" />;
156+
};
157+
158+
const AppFixed: React.FC = () => {
159+
return <ReflectedInput color="red" />;
160+
};
161+
expectType<React.FC>(App);
162+
expectType<React.FC>(AppFixed);
163+
}
164+
165+
// reflect should not allow to override "binded" props with wrong types
166+
{
167+
const Input: React.FC<{
168+
value: string;
169+
onChange: (newValue: string) => void;
170+
color: 'red';
171+
}> = () => null;
172+
const $value = createStore<string>('');
173+
const changed = createEvent<string>();
174+
175+
const ReflectedInput = reflect({
176+
view: Input,
177+
bind: {
178+
value: $value,
179+
onChange: changed,
180+
color: 'red',
181+
},
182+
});
183+
184+
const App: React.FC = () => {
185+
return (
186+
<ReflectedInput
187+
// @ts-expect-error
188+
color="blue"
189+
/>
190+
);
191+
};
192+
expectType<React.FC>(App);
193+
}
194+
107195
// reflect should allow to pass EventCallable<void> as click event handler
108196
{
109197
const Button: React.FC<{

type-tests/types-variant.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,6 @@ import { expectType } from 'tsd';
243243
<ComponentWithVariantAndReflect slot={<h2>Another slot type(</h2>} />
244244
<ComponentWithVariantAndReflect
245245
slot={<h2>Should report error for "name"</h2>}
246-
// @ts-expect-error
247246
name="kek"
248247
/>
249248
</main>

0 commit comments

Comments
 (0)