Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeScript definitions #21

Open
corydeppen opened this issue May 20, 2018 · 36 comments
Open

TypeScript definitions #21

corydeppen opened this issue May 20, 2018 · 36 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@corydeppen
Copy link

Would you be willing to include a TypeScript declaration file in this repo and publish it as part of the package? I'm interested in using this library in a TypeScript project and would be willing to work on a PR that uses the existing Flow types as the basis for the TS definitions.

@aweary
Copy link
Owner

aweary commented May 21, 2018

I would love to have a TypeScript definition! I need to get a working library definition for Flow too (right now the types are internal) so the timing is great.

@aweary aweary added enhancement New feature or request help wanted Extra attention is needed labels May 21, 2018
@samhh
Copy link

samhh commented Jun 20, 2018

These aren't production-ready, but they're a start.

declare module 'react-copy-write' {
	import { Component } from 'react';

	interface ProviderProps {
		children: JSX.Element | JSX.Element[];
		initialState?: object;
	}

	class Provider extends Component<ProviderProps, any> {}

	type MutateFn<T> = (draft: T) => void;
	type Mutator<T> = (mutator: MutateFn<T>) => void;

	// "any" due to selector
	type RenderFn<T> = (state: T | any, mutate: MutateFn<T>) => JSX.Element | JSX.Element[];

	interface ConsumerProps<T> {
		selector?: (state: T) => any;
		render?: RenderFn<T>;
		children?: RenderFn<T>;
	}

	class Consumer<T> extends Component<ConsumerProps<T>, any> {}

	function create<T extends object>(state: T): {
		Provider: new() => Provider,
		Consumer: new() => Consumer<T>,
		mutate: Mutator<T>,
	};

	export default create;
}

@davej
Copy link

davej commented Jun 27, 2018

@samhh Would you consider publishing these somewhere so that those of us using typescript can iterate on them?

@samhh
Copy link

samhh commented Jun 27, 2018

@davej I've created a formal type definition here that can be merged into DefinitelyTyped via PR once they're a bit further along. Feel free to contribute there until it's ready for PR; speaking of which, I've not submit anything to DT before so any feedback on that directory is helpful, particularly with regards to the "test".

@aweary
Copy link
Owner

aweary commented Jun 27, 2018

Im not a TypeScript user. Is it more common for type definitions to live in Definitely Typed or the repo itself?

Happy to do do either.

@davej
Copy link

davej commented Jun 27, 2018

@aweary I'm only starting with typescript but I think it's better to include them in the repo itself if possible. DT is for the community to provide an escape hatch when a library decides not to include type definitions for whatever reason.

@davej
Copy link

davej commented Jun 27, 2018

I've not submit anything to DT before so any feedback on that directory is helpful

@samhh Either have I!
This is literally my first week using TypeScript (starting a new greenfields project). I'm not sure if I can be much help but I'll try my best. :-)

@samhh
Copy link

samhh commented Jun 27, 2018

@aweary @davej The TypeScript documentation here says that if the project is not written in TypeScript then it's better for the types to be stored in the DefinitelyTyped repo.

@davej
Copy link

davej commented Jun 27, 2018

@samhh Ah ok. I was basing my answer on what I read on StackOverflow but TypeScript documentation is a much more canonical source.

@aweary
Copy link
Owner

aweary commented Jun 27, 2018

Note that the API has gone through some changes since @samhh's original attempt.

  • mutate is no longer passed to the render callback of consumers
  • selector has been renamed select and must be an array of selector functions
  • The render callback for consumers no longer pass the result of the selectors as an array. Instead they are now arguments to the function.

A couple other things:

  • Does using any with the selector cause it to lose the types, or can it still infer based on what the selector returns? Maybe a generic is more appropriate?
  • Can you define "conditional mutability" in TypeScript? For example, in mutate the state is mutable, but in the Consumer callback the state is not. It'd be awesome if we can represent that with types

@samhh
Copy link

samhh commented Jun 27, 2018

@aweary Yes, using any means you're basically telling the compiler that you don't know what it is. I'm unsure how to solve this, any feedback from more experienced TS folk is obviously welcome.

With regards to conditional mutability, I think we could use Readonly generic and its variants to accomplish that?

@samhh
Copy link

samhh commented Jul 6, 2018

Made the PR here with the help of @davej. Hopefully they'll be up soon-ish!

@samhh
Copy link

samhh commented Jul 6, 2018

It's merged and should be available on npm shortly. 🎊

Edit: It's up! 🎊

@aweary
Copy link
Owner

aweary commented Jul 6, 2018

Thanks you both @samhh @davej! I definitely want to figure out a way to avoid any but this is a great start. I'll share the flow definitions we have internally soon, maybe that will be a good reference.

@samhh
Copy link

samhh commented Jul 7, 2018

As I alluded to in the comments of the type definition I had two ideas to solve the any issue but both involve features not yet offered by TypeScript.

  1. ConsumerProps union. This doesn't work because of type-widening; the absence of the select property does not disqualify the select-less interface. I tried to instead make the select-less interface define select as undefined, but that didn't work either.

  2. Variadic kinds. My memory of what didn't work here is a bit rusty, but as I remember it: It would involve a few more changes, but in essence this plus conditional inferrence could work. My stumbling block was my inability to map a tuple of functions into a tuple of return types.

Of course, it's possible that there's a better way that would work today, but if so it's totally passed me by! 😛

@davej
Copy link

davej commented Jul 11, 2018

@samhh: I'm seeing an error when using the second param (readable state) of the mutate function.

For example, in the code below:

// store.ts
export interface IApp {
  name: string;
  url: string;
  iconUrl?: string;
  icons?: UploadFile[];
}

// actions.ts
export const updateApp = (props: Partial<IApp>) =>
  mutate((draft, state) => {
    const appIndex = state.selectedApp;
    const app = state.apps[appIndex];
    // ▼▼ TS ERROR ON LINE BELOW ▼▼
    draft.apps[appIndex] = {
      ...app,
      ...props
    };
  });

The error that I'm getting is the following:

Type 'DeepReadonly<UploadFile[]>' is not assignable to type 'UploadFile[]'.

I could change the mutate function to just use the first param (draft) and read from that but it would be less performant because it's going through the Proxy object. Any ideas on how we could update the definition to get this working?

edit: Definition for UploadFile is here: https://github.com/ant-design/ant-design/blob/master/components/upload/interface.tsx#L13

@aweary
Copy link
Owner

aweary commented Jul 12, 2018

@davej not sure about the TypeScript error, but the action you have there is doing more updates than necessary. You don't need to spread app or create a new object. Just mutate the existing one!

export const updateApp = (props: Partial<IApp>) =>
  mutate((draft, state) => {
    const appIndex = state.selectedApp;
    for (const key in props) {
        draft.apps[appIndex][key] = props[key]
     }
  });

@davej
Copy link

davej commented Jul 12, 2018

@aweary: That's true. I guess the issue that I was pointing out still stands though. There are cases where you may wish to assign something from state to draft and the current Typescript definitions output an error when doing that with complex objects.

@samhh
Copy link

samhh commented Jul 12, 2018

There is unfortunately not yet a DeepReadonly in the standard library (see here), so I added one myself that has unfortunately proven naive. I'm making a PR to change it to Readonly; it will only prevent top-level mutation, but it does fix the above issue.

Edit: PR up.

@samhh
Copy link

samhh commented Jul 13, 2018

Back to the any issue, I still haven't cracked it but I think this is better than what we have now. Give it a try and let me know how it works for you (chuck it inside a declare module 'react-copy-write' {} in a .d.ts file after uninstalling @types/react-copy-write)!

import { Component } from 'react';

type MutateFn<T> = (draft: T, state: Readonly<T>) => void;
type Mutator<T> = (mutator: MutateFn<T>) => void;

type SelectorFn<T, U> = (state: T) => U;

// Below probably works 3.0+
// type RenderFn<T extends any[]> = (...state: Readonly<T>) => JSX.Element | JSX.Element[] | null;
type RenderFn<T extends any[]> = (
    datum1: Readonly<T[0]>, datum2: Readonly<T[1]>, datum3: Readonly<T[2]>, datum4: Readonly<T[3]>, datum5: Readonly<T[4]>, datum6: Readonly<T[5]>, datum7: Readonly<T[6]>, datum8: Readonly<T[7]>, datum9: Readonly<T[8]>, datum10: Readonly<T[9]>,
) => JSX.Element | JSX.Element[] | null;

interface ConsumerProps<T, U extends any[]> {
    // Below probably works 3.0+
    // select?: [SelectorFn<T, U[0]>?, SelectorFn<T, U[1]>?, SelectorFn<T, U[2]>?, SelectorFn<T, U[3]>?, SelectorFn<T, U[4]>?, SelectorFn<T, U[5]>?, SelectorFn<T, U[6]>?, SelectorFn<T, U[7]>?, SelectorFn<T, U[8]>?, SelectorFn<T, U[9]>?];
    select?:
        [SelectorFn<T, U[0]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>, SelectorFn<T, U[5]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>, SelectorFn<T, U[5]>, SelectorFn<T, U[6]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>, SelectorFn<T, U[5]>, SelectorFn<T, U[6]>, SelectorFn<T, U[7]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>, SelectorFn<T, U[5]>, SelectorFn<T, U[6]>, SelectorFn<T, U[7]>, SelectorFn<T, U[8]>] |
        [SelectorFn<T, U[0]>, SelectorFn<T, U[1]>, SelectorFn<T, U[2]>, SelectorFn<T, U[3]>, SelectorFn<T, U[4]>, SelectorFn<T, U[5]>, SelectorFn<T, U[6]>, SelectorFn<T, U[7]>, SelectorFn<T, U[8]>, SelectorFn<T, U[9]>];
    render?: RenderFn<U>;
    children?: RenderFn<U>;
}

declare class Consumer<T, U extends any[]> extends Component<ConsumerProps<T, U>> {}

interface ProviderProps<T> {
    children: JSX.Element | JSX.Element[];
    initialState?: Partial<T>;
}

declare class Provider<T> extends Component<ProviderProps<T>> {}

declare function create<T extends object>(state: T): {
    Provider: new() => Provider<T>,
    Consumer: new<U extends any[] = [T]>() => Consumer<T, U>,
    createSelector: <U extends SelectorFn<T, any>>(selector: U) => U,
    mutate: Mutator<T>,
};

export default create;

I've added a generic to the Consumer component. This type represents the array of arguments you're going to receive back in your render functions, so it defaults to [T] where T is your state/store object. If you pass in any selectors, then you'll need to manually type the generic. An example is below:

import * as React from 'react';
import { render } from 'react-dom';
import createStore from 'react-copy-write';

const { Provider, Consumer, mutate, createSelector } = createStore({
    user: {
        avatar: {
            src: '',
        },
        age: 42,
    },
});

// Create reusable selectors for optimal performance
const selectAvatar = createSelector(state => state.user.avatar.src);
const selectAge = createSelector(state => state.user.age);

// Use state for lookup of preexisting data instead of draft (per underlying
// immer library)
const incrementAge = () => mutate((draft, state) => {
    draft.user.age = state.user.age + 1;
});

const App = () => (
    <Provider>
        <div>
            <Consumer<[ReturnType<typeof selectAvatar>, ReturnType<typeof selectAge>]> select={[selectAvatar, selectAge]}>
                {(avatarSrc, age) => (
                    <>
                        <img src={avatarSrc} />

                        <p>User is {age} years old</p>
                    </>
                )}
            </Consumer>

            <Consumer render={state => (
                <button onClick={incrementAge}>Increment age</button>
            )} />
        </div>
    </Provider>
);

render(<App />, document.body);

Where I've gone for the very safe [ReturnType<typeof fn>], you could instead do simply [string, number, etc] for brevity.

There are some caveats to this approach.

  1. You're forced to manually type your components. This is annoying because the type information is there, I just can't figure out any way to access it within TypeScript today.
  2. You cannot use more than 10 selectors without reverting back to needing to assert your types. That said, who is using 10 selectors? 🔔
  3. If you for example use two selectors and you type them accordingly, any further arguments you attempt to extract in your render function will wrongly infer type as a union of the preceding values e.g. args [number, string] would infer any further arguments as number | string, which appears to be the default behaviour in TypeScript when accessing an array/tuple at an undefined index (can be simulated with MyArray[number].
  4. The type definition is much less readable, but I've left some commented alternatives in that I believe will work in TS 3.0+.

Oh, and I'm not sure because I hadn't yet used any selectors myself, but I think the createSelector types are much improved now.

@samhh
Copy link

samhh commented Jul 21, 2018

^^ Please try and feedback the above before I submit a PR, don't want to introduce any more instability (a la the DeepReadonly issue). 😄

@davej
Copy link

davej commented Jul 22, 2018

@samhh: Using it now, I've made a note to report back after a few days of use.

@davej
Copy link

davej commented Jul 25, 2018

@samhh: So far, so good. Anything in particular that I should be looking out for?

@samhh
Copy link

samhh commented Jul 26, 2018

@davej Aside from generally noticing any potential regressions, I'm just wondering if for others those specified caveats are worthwhile. I think they are but I want feedback before I thrust them upon everyone!

@davej
Copy link

davej commented Jul 26, 2018

@samhh:

Well I had trouble doing the following (based on your example):

const selectApp = createSelector(state => state.apps[state.selectedApp]);

const BuildContainer: React.SFC = () => (
  <Consumer<[ReturnType<typeof selectApp>]> select={[selectApp]}>
    {(app) => <Build app={app} />}
  </Consumer>
);

The <[ReturnType<typeof selectApp>]> signature seems to cause a build error? I'm using Typescript 2.9.2.

@samhh
Copy link

samhh commented Jul 27, 2018

VSCode isn't giving me any errors, what's the build error? @davej

@davej
Copy link

davej commented Jul 27, 2018

I'm not getting a build error, just "Failed to compile" from typescript and tslint is passing. I'm using @babel/preset-typescript and react-app-rewire-typescript-babel-preset so potentially it is a bug in one of those libraries?

@samhh
Copy link

samhh commented Jul 27, 2018

Hmm, I think that would suggest an issue with those; I've never personally seen TypeScript fail without telling you why, however verbose or cryptic.

@davej
Copy link

davej commented Jul 27, 2018

Yes, it does that on all errors. Usually TSLint will catch the error so it hasn't really been an issue for me so far.

Created an issue on the repo of the boilerplate I'm using to see if I can get to the bottom of it. Jayphen/Boilerplate-CRA2-Typescript-Emotion#2

@davej
Copy link

davej commented Jul 27, 2018

Ok I downgraded to [email protected] and now I am getting the following error:

./src/steps/6-Build.tsx
Syntax error: {snip}/src/steps/6-Build.tsx: Unexpected token (153:11)

  151 | const selectApp = createSelector(state => state.apps[state.selectedApp]);
  152 | const BuildContainer: React.SFC = () => (
> 153 |   <Consumer<[ReturnType<typeof selectApp>]> select={[selectApp]}>
      |            ^
  154 |     {(app: IApp) => <Build app={app} />}
  155 |   </Consumer>
  156 | );

Perhaps this syntax just isn't supported in babel react for some reason. Do you know if there is an alternative syntax for doing this?

@samhh
Copy link

samhh commented Jul 27, 2018

Not that I know of. That syntax - allowing you to create generic React components, passing the arguments in via JSX - was introduced in TS 2.9.0.

@lanwin
Copy link

lanwin commented Aug 16, 2018

I would suggest to introduce a createConsumer(selector1,selector2....) function and remove the select attribute from the consumer. This way we could create type safe consumers and can remove any.

Here are some demo typeings how that could look like: https://gist.github.com/lanwin/67ab9a1e6fd89dfb32eda525d4cd0def

The code then could look like:

const { createSelector, createConsumer } = createStore(storeData);

const fornameSelector = createSelector(state => ({
  forname: state.name.split(" ")[0]
}));
const surenameSelector = createSelector(state => ({
  surename: state.name.split(" ")[1]
}));

const SelectorConsumer = createConsumer(fornameSelector, surenameSelector);

export default function render() {
  return (
    <div>
      <SelectorConsumer>
        {state => <span>{state.surename}</span>}
      </SelectorConsumer>
    </div>
  );
}

@lanwin
Copy link

lanwin commented Aug 17, 2018

@aweary what do you think about changing the Consumer to not longer take a select attribute and instead add a createConsumer function to create a consumer with specialized state.

@gnapse
Copy link

gnapse commented Sep 2, 2018

I'm using the following approach as a workaround:

interface State {
  // this is my state type
}

const { Provider, Consumer } = createState<State>(emptyState);

interface DataConsumerProps<T> {
  selector: (state: State) => T;
  children: (selectedState: T) => React.ReactNode;
}

// I use this component instead of `Consumer`
class DataConsumer<T> extends Component<DataConsumerProps<T>> {
  public render() {
    const { selector, children } = this.props;
    return <Consumer select={[selector]}>{children}</Consumer>;
  }
}

Then in my views I use it like this:

// Imagine this hypothetical state
interface State {
  todoIds: string[];
  todos: { [key: string]: TodoItem };
}

interface TodoViewProps {
  todoId: string;
}

class TodoView extends Component<TodoViewProps> {
  render() {
    return (
      // The type argument for DataConsumer has to be given explicitly, even though
      // I kind of expected that it would be inferred from the return type of the selector.
      <DataConsumer<TodoItem>
        selector={state => state.todos[this.props.todoId]}
      >
        {todoItem => (
          // ...
        )}
      </DataConsumer>
    );
  }
}

The tradeoff of having a single selector is not a problem. I generally select little from the state. But even if it's more, I can bundled all I need in an object, instead of having more than one selector. Not sure though if this has implications vs. passing several selectors and receiving the results as different arguments in the render prop function.

@lanwin
Copy link

lanwin commented Sep 4, 2018

@gnapse the drawback of this approch is that you can not get type safty between the state in the store and the DataConsumer. You can basically use any type you want as T for DataConsumer without even noticing at compile time that it does not match with your State.

@lanwin
Copy link

lanwin commented Sep 4, 2018

Ah sorry it seems I am wrong. Forget the above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

6 participants