relic
relic

Reputation: 1704

How can I use a string argument to enforce type-checking against a second argument?

I'm developing a new web framework of my own, and I'm running into a type-checking issue that I'm struggling to figure out.

The problem at a high level is, I have some methods that are responsible for instantiating new classes within the framework and I have a function signature that should take a string as the first arg and an object as its second one. It should then use that string as a key to type-check the object and to provide the correct return type.

Here's a minimal reproducible sample. Copy / paste to a file and save with a filename, then update "declare module" to use filename.

// Provided by the framework
interface BaseViewOptions {
  nothing: string;
}

class BaseView {
  constructor(options?: BaseViewOptions) {
    console.log('Base class', options);
  }
}

interface ViewType {
  [registrationId: string]: { classRef: typeof BaseView; options: BaseViewOptions };
}

// User's code
interface MyViewOptions extends BaseViewOptions {
  something: string;
}

class MyView extends BaseView {
  constructor(options?: MyViewOptions) {
    super(options);
    console.log('Created MyView', options);
  }
}

// Generated references:

// Map used for type checks
declare module './TEST_FILE' {
  // To test, save code as a file and point this path to the current file name
  // In the actual code base all of these pieces are in seperate files.
  interface ViewType {
    'my-view': {
      classRef: typeof MyView;
      options: MyViewOptions;
    };
  }
}

// Map used for class instantiations at run-time
export const classReferences: Record<string, { classRef: typeof BaseView }> = {
  'my-view': {
    classRef: MyView,
  },
};

// Utility method in the framework
function newView(
  id: keyof ViewType,
  options?: ViewType[typeof id]['options'],
): Promise<InstanceType<ViewType[typeof id]['classRef']>> {
  return Promise.resolve(new classReferences[id].classRef(options));
}

(async () => {
  // Caller from user's code

  // Type of const here should be "MyView" rather than "BaseView"
  const myView = await newView('my-view', {
    nothing: 'test1',
    something: 'test2', // this property shouldn't throw an error
  });

  console.log(myView);
})();

Design goals:

  1. The function signature should not require callers to pass in generic type arguments or use casting.
  2. No forced usage of an enum or other manual key set. (hence, a string)
  3. The second argument's type should be informed by the first argument's value.
  4. The return type should also be informed by the first argument's value.

The second argument is supposed to be type-checked against ViewType[typeoof id]['options'], or MyViewOptions, and the return value for myView should now have a type of InstanceType<ViewType[typeof id]['classRef']>, or InstanceType<MyView>. But, instead what's happening is the options are checked against BaseViewOptions and the return type is BaseView. In other words, its falling back to the default that was defined when we provided the index signature.

I think the behavior I was assuming and am depend on, but am not getting... is for TypeScript to receive an id of "my-view" and treat the actual type of that value as "my-view". But, since we have the index on ViewType interface that defines the id type as a string, that gives TypeScript the freedom to widen the type to just "string". And when we check our interface map for MyView['string'], we don't see anything and the compiler falls back to what it's doing now.

If I'm right about the source of the problem, I need a way to tell TS to assume the type for 'my-view' is 'my-view', without requiring the user to provide anything additional in the caller (generics or casting).

Anyway, hope this isn't too weird of a question. Thanks for any help you can provide.

Upvotes: 1

Views: 74

Answers (2)

jcalz
jcalz

Reputation: 329858

If you want a function's return type to depend on its input type, your only options are to make the function generic or to overload it. Overloads only really work if you have a finite and fixed set of call signatures that you know in advance. For anything more dynamic, you probably want your function to be generic.

Note that the typeof type operator does not make a function generic or behave in a generic way. If a function parameter id is of type keyof ViewType, then typeof id is just another way of writing keyof ViewType. It doesn't implicitly make id more specific than keyof ViewType. If you want that behavior, you need to make id's type generic explicitly. So let's do that:

declare function newView<K extends keyof ViewType>(
  id: K, options?: ViewType[K]["options"]
): Promise<InstanceType<ViewType[K]["classRef"]>>;

Now when we call it, we see the behavior you expect:

const myView = await newView('my-view', {
  nothing: 'test1',
  something: 'test2', // okay
});
// const myView: MyView

const otherView = await newView('random', { nothing: "xyz" });
// const otherView: BaseView

The type of myView is MyView as desired because the generic type argument K is inferred as 'my-view', which is a known specific key of ViewType and thus the options and classRef are specific. On the other hand the type of otherView is just BaseView because the generic type argument K is inferred as "random", which only matches the string index signature of ViewType. (Do note that the implementation of newView in the example wouldn't actually return anything for otherView; presumably the real code would have to have a default case... but this is out of scope for the question as asked.)


So that works. Note that you could write newView's type to effectively be overloaded instead of generic, but it would involve a lot of type juggling to generate the requisite combination of call signatures. Overloads are equivalent to intersections of function types, and you can use a technique like Transform union type to intersection type to programmatically generate intersections. It looks like

type NewViewFor<K extends keyof ViewType> =
  (id: K, options?: ViewType[K]['options']) => Promise<InstanceType<ViewType[K]['classRef']>>;

declare const newView: { [K in keyof ViewType as string extends K ? never : K]:
    (x: NewViewFor<K>) => void
  } extends Record<string, (x: infer I) => void> ? I & NewViewFor<string> : never;

which makes newView of type NewViewFor<'my-view'> & NewViewFor<string> and therefore you get the same call behavior as before:

const myView = await newView('my-view', {
  nothing: 'test1',
  something: 'test2', // okay
});
// const myView: MyView

const otherView = await newView('random', { nothing: "xyz" });
// const otherView: BaseView

There might be some advantage to having newView() as an overload, as each way of calling it will appear separately in IntelliSense, but it's more complicated... not least because you need to be careful that NewViewFor<string> ends up as the very last call signature, since you don't want to select that if id is any of the known keys of ViewType. I'd say that generics should be the preferred approach, all else being equal.

Playground link to code

Upvotes: 1

majixin
majixin

Reputation: 321

I think part of the issue here is that the lookup intuitively feels like it gets the type information, when really it just gets you a ViewType interface, which is not concrete type information you can instantiate. You fundamentally need the concrete type information to do what you want.

So step by step...

Your newView function uses generics which goes against your primary design goal.

The signature of it is mostly correct, but it doesn't show an implementation to create a class using the id.

If you're going to want to return a promise, it will need to be async, and to meet your primary design goal, lose the generics and make it something like:

async function newView(id: keyof ViewType, options?: ViewType[typeof id]['options']): Promise<InstanceType<ViewType[typeof id]['classRef']>>

Then you need to get the concrete type information using the classRef because it's literally the typeof Whatever that you want:

const ViewClass = ViewType[id].classRef;

^The fact classRef is literally your concrete type information is super-easy to miss! You need to leverage it to be able to construct the concrete type implementing the ViewType interface. This I think:

return new ViewClass(options) as InstanceType<ViewType[typeof id]['classRef']>;

So more fully:

async function newView(id: keyof ViewType, options?: ViewType[typeof id]['options']): Promise<InstanceType<ViewType[typeof id]['classRef']>> {
    const ViewClass = ViewType[id].classRef;
    return new ViewClass(options) as InstanceType<ViewType[typeof id]['classRef']>;
}

Upvotes: 1

Related Questions