solidjs / solid

A declarative, efficient, and flexible JavaScript library for building user interfaces.
https://solidjs.com
MIT License
32.4k stars 922 forks source link

Switch/Match with Type Predicates (TypeScript) #199

Closed benrbray closed 4 years ago

benrbray commented 4 years ago

I'm using SolidJS with TypeScript. I'm having trouble using Switch / Match with TypeScript discriminated unions, since type predicates in the when={} prop of Match are not remembered by the children. For example:

interface IFile { type : "file"; fileName: string; }
interface IFolder { type: "folder"; folderName: string; }
type IDirEntry = IFile | IFolder;

interface IExplorerProps { files: IDirEntry[]; }

const isFile = (e:IDirEntry):e is IFile => (e.type == "file");
const isFolder = (e:IDirEntry):e is IFolder => (e.type == "folder");

export const Explorer = (props:IExplorerProps) => {
    return (<For each={props.files} fallback={<div>Empty!</div>}>
        {(entry:IDirEntry)=>(
            <Switch>
                <Match when={isFile(entry)}>
                    {/* ERROR: Property 'fileName' does not exist on type 'IFolder'. */}
                    <div>{entry.fileName}</div>
                </Match>
                <Match when={isFolder(entry)}>
                    {/* ERROR: Property 'folderName' does not exist on type 'IFile'. */}
                    <div>{entry.folderName}</div>**
                </Match>
            </Switch>
        )}
    </For>);
}

The current type definitions for Switch / Match are:

export declare function Switch(props: {
    fallback?: JSX.Element;
    children: JSX.Element;
}): () => JSX.Element;
declare type MatchProps<T> = {
    when: T | false;
    children: JSX.Element | ((item: T) => JSX.Element);
};
export declare function Match<T>(props: MatchProps<T>): JSX.Element;

I tried fiddling around with the types but couldn't really get it to work properly. Any idea whether this sort of thing is possible, or any ideas for a workaround?

ryansolid commented 4 years ago

Yeah this is beyond my TypeScript knowledge. I call forth any TypeScript gurus to help out.

benrbray commented 4 years ago

How about this? It would require Match to accept an item parameter, making the Switch / Match pattern behave more like a real switch / case:

export declare function Switch<T>(props: {
    fallback?: JSX.Element;
    children: JSX.Element;
}): () => JSX.Element;

declare type MatchProps<T> = {
    item: any;
    when: (t:any) => t is T;
    children: ((item:T) => JSX.Element);
}
export declare function Match<T>(props: MatchProps<T>): JSX.Element;

Usage:

export const Explorer = (props:IExplorerProps) => {
    return (<div id="tab_explorer"><For each={props.files} fallback={<div>Empty!</div>}>
        {(entry:IDirEntry)=>(
            <Switch>
                <Match item={entry} when={isFile}>
                    {(item) => (<div>{item.fileName}</div>)}
                </Match>
                <Match item={entry} when={isFolder}>
                    {(item) => (<div>{item.folderName}</div>)}
                </Match>
            </Switch>
        )}
    </For></div>);
}
ryansolid commented 4 years ago

I already use a similar function form for passing through the when item. Does this actually help? All I see is the wrapper is essentially casting it to any which lets it not complain. I'm not sure if TypeScript will be smart enough to handle this scenario with JSX. You want it to recognize due to the condition being true that the type is of a certain type. That's going to be challenging since there are a lot of intermediate wrapped functions. I never say never with TypeScript but I'm not sure how it would be able to connect the dots in this case.

benrbray commented 4 years ago

Thanks for the reply! You're right that the any is a problem. I tried some more complicated type expressions including something as ugly as

// (doesn't actually work)
type MatchProps<T extends S, W extends ((t:S)=>t is T), S=unknown> = {
  item: S;
  when: W;
  children: (item: W extends ((t:S)=>t is Extract<infer P, S>) ? P : never) => JSX.Element;
}

Thankfully, I eventually came up with this workaround, that doesn't require any changes to SolidJS:

Workaround

const admitFile = (e:IDirEntry):IFile|false => (e.type == "file") ? e : false;

const Explorer = (props:IExplorerProps) => { return (
  <Switch>
    <Match when={admitFile(props.entry)}>
      {(item) => (<div>{item.fileName}</div>)}
    </Match>
  </Switch>
)};

This isn't an ideal solution, but it definitely works!

image

And the generic version:

function matches<S extends T, T=unknown>(e:T, predicate:((e:T) => e is S)):S|false {
  return predicate(e) ? e : false;
}

const Explorer = (props:IExplorerProps) => { return (
  <Switch>
    <Match when={matches(props.entry, isFile)}>
      {(item) => (<div>{item.fileName}</div>)}
    </Match>
  </Switch>
)};
ryansolid commented 4 years ago

Yeah your first example is where I thought this was heading which might have been hard to generalize. In any case I'm happy you figured out a solution. It isn't perfect but it fits the case. It is probably possible for yourself or someone interested to make a custom control flow with your exact desired behavior (matching for subtyping) and have the typing work but as a generic conditional I think it might be convoluted.

In any case I'm happy you found a solution, and thank you for posting it.