Search code examples
typescript

Use argument value as type key in declaration file


I'm working to create a Prisma extension. For reasons, I started with JavaScript, using JSDoc hints for typing. The extension actually works just fine; however, the type hints are terrible, which led me to believe I was doing something wrong. In fact, when I switched to a typescript transpiler, all sorts of errors appeared in my declaration files that were invisible when using esbuild. My biggest current challenge in squashing these is, I need to use a value provided by the user when the initial extension is instantiated as a key in various other type declarations in my declaration files.

For example:

//file: index.d.ts
import { MyMethod1Args } from './method_1.d.ts';

// these are mocks; really imported from Prisma
type SomeType = 'id' | 'foo';
interface SomeOtherType {
  foo: string;
  bar: number;
}

export interface RequiredArgs {
  modelName: string;
  idFieldName?: SomeType; // want to use user value of this as key elsewhere
}

// what I want to do
export type IdField = Pick<SomeOtherType, `${idFieldName}`>;
export type IdFieldKey = keyof IdField; // maybe typeof keyof IdField?

export declare function myExtendsion<I extends RequiredArgs>(args: I): {
  Method1(args: MyMethod1Args): string
}

That (retrospectively misguided) attempt to use a Template Literal, is where I'm trying to create a type definition which depends on the user input. There are a few dozen of these across the code.

The reason to have it this way is, as I mentioned, the JavaScript is already working, and already "typed" with JSDoc hints, and I'd like to migrate it, but slowly — not having to rewrite every JS file up front. Right now, those JSDoc hints are imported from other declaration files, so I need to be able to use those IdField and IdFieldKey in other places. For example:

//File: method_1.d.ts
import { IdFieldKey } from '$type/index.d.ts';

// trying for MyMethod1Args = { foo: { in: string[] } },
// where "foo" was the value supplied to `idFieldName`.
export type MyMethod1Args = {
  [K in idFieldKey]: { in: string[] }
};

Note: This may or may not be relevant to the answer, but I'm including it here in the event that it impacts things, and to help explain how this is actually being used. What I've done in the JavaScript is before exporting the individual methods from the main function, I use a HoF to wrap them all and pass in the top level args as configArgs, so that each method has access to the parameters (e.g., idFieldName. But I don't include that in the function signature, so it's not confusing in the Intellisense or whatever.

// File: Method_1.js
/**
 * @param {import('$types/method_1.d.ts').MyMethod1Args} args
 * @returns string
 */
export default function (args, configArgs) {
    console.log(args[configArgs.idFieldName].in.join());
}

Solution

  • To address the MyExtension example in the question without worrying about the relationship between modelName and idFieldName, I'd write a declaration like this:

    declare function MyExtension<K extends PropertyKey = "id">(
        arg: { modelName: string, idFieldName?: K }
    ): {
        MyExtensionsMethod(arg: { [P in K]: boolean }): Promise<void>;
    }
    

    Here the function is generic in K, the type of idFieldName. This type is constrained to PropertyKey (which is just an alias of string | number | symbol, precisely the types which can be keys for properties), and which defaults to id (the default will only happen if K cannot be inferred, i.e., when no argument is passed for idFieldName).

    Then it returns an object with a method accepting an argument of type {[P in K]: boolean} which is the same as Record<K, boolean> using the Record utility type. It's a simple mapped type that has a property with key of type K and value of type boolean (and is presumably what you meant by {[K]: boolean}).

    Let's test it out:

    const UserMethods1 = MyExtension({ modelName: 'foo', idFieldName: 'bar' });
    const newValue1 = await UserMethods1.MyExtensionsMethod({ bar: true });
        
    const UserMethod2 = MyExtension({ modelName: 'foo' });
    const newValue2 = await UserMethod2.MyExtensionsMethod({ id: true });
    

    Looks good. For UserMethods1.MyExtensionMethod a {bar: boolean} is expected, whereas for UserMethod2.MyExtensionsMethod an {id: boolean} is expected.

    If you need to relate modelName to idFieldName you can presumably make this doubly generic so that you have

    declare function MyExtension<M extends ModelNames, K extends KeysFor<M> | "id" = "id">(
        arg: { modelName: M, idFieldName?: K }
    ): {
        MyExtensionsMethod(arg: { [P in K]: boolean }): Promise<void>;
    }
    

    where ModelNames and KeysFor are properly defined, but I'm going to consider that out of scope here, since that part should be easy enough to hook up.

    Playground link to code