Typescript Mysterious Intersection

2019-08-11 02:03发布

问题:

TLDR: Playground Repro

In my application, I'm defining multiple form modules which look roughly like:

const firstModule = {
    name: 'firstModule',
    mutation: () => {
        return (opts: {variables: {firstModuleArg: string}}) => {} 
    }
}

const secondModule = {
    name: 'secondModule',
    mutation: () => {
        return (opts: {variables: {secondModuleArg: number}}) => {} 
    }
}

As you can see, each mutation function returns a function that expects a particularly shaped variables field.

Usage of each module directly works just fine:

firstModule.mutation()({ variables: { firstModuleArg: 'test' } }); => ok

secondModule.mutation()({ variables: { secondModuleArg: 123 } }); => ok

However, I'm also creating a central registry of these forms so that I can look them up from elsewhere like so:

const forms = {
    firstModule,
    secondModule
}


const getFormConfig = (root: 'firstModule' | 'secondModule') => {
    const rootObj = forms[root];

    return rootObj;
}

This is where the issue is.. When I then try to refer to a single member of the combined form object, it seems like Typescript is automatically creating an intersection of the variables fields and throwing the following error:

const { mutation: firstModuleMutation } = getFormConfig('firstModule');

firstModuleMutation()({ variables: { firstModuleArg: '1234' } });

I imagine I'm missing something fairly simple here, but was hoping to get some insight into how to get the ideal behavior (when I specifically retrieve the firstModule, I only want it to validate the variables field from that module). Please let me know if there's any other information I can provide.

Thanks!

回答1:

When a function is defined this way, TypeScript loses the relation between your module name and your mutation return type.

You can either use function overloads or define your function using type parameters. Since the first solution was already provided, let me present the second approach. Its advantage is that it scales indefinitely. If you decide to extend your model, it will just work, whereas with overloads you would have to update them every time your model changes.

We will need a few commonly used helpers first.

type ValueOf<T> = T[keyof T];
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;

Your domain model:

/**
 * Type aliases.
 */
type Forms = typeof forms;
type Module = ValueOf<Forms>;

/**
 * The return type for `getFormConfig`.
 */
type TransformedModule<T extends Module> = Overwrite<T, { mutation: ReturnType<T['mutation']> }>;

The final solution:

export function getFormConfig<K extends keyof Forms>(arg: K) {
  const module = forms[arg];

  return ({ ...module, mutation: module.mutation() }) as TransformedModule<Forms[K]>;
}

Usage:

getFormConfig('firstModule').mutation({ variables: { firstModuleArg: 'foo' } })
getFormConfig('secondModule').mutation({ variables: { secondModuleArg: 42 } });


回答2:

You could help the compiler with overloads:

function getFormConfig(root: 'firstModule'):
    typeof firstModule & { mutation: ReturnType<typeof firstModule.mutation> }
function getFormConfig(root: 'secondModule'):
    typeof secondModule & { mutation: ReturnType<typeof secondModule.mutation> }
function getFormConfig(root: 'firstModule' | 'secondModule') {
    const rootObj = forms[root];

    const mutation = rootObj.mutation();
    return {...rootObj, mutation}
}