Home > Blockchain >  Extending generic function parameter type - Typescript
Extending generic function parameter type - Typescript

Time:11-09

I have just recently started to play around with advanced types and generics in Typescript so please excuse if my formulation is not entirely correct - at least I hope the title makes sense.

UPDATE -> I have added a full code example here

What I want to do

Assume two (query) functions, where one takes only an object as parameter and the other one takes a query string and an object as parameter.

// only object
const query1 = ({ ...params }) => { return ...; }

// query AND object
const query2 = (query, { ...params }) => { return ...; }

Both functions are contained in separate classes where they also get typed

class Q1 {
  private query1: Query1;

  ...
}

class Q2 {
  private query2: Query2;

  ...
}

Type definitions

Basically, what I am trying to achieve is that both function types derive from a single generic type. What I have so far is


// the generic object type --> I guess here I would need to define it differently
type Param<T> = { [key in keyof T]: T[key] }

// the generic query type which contains `params` and `response` types
type QueryType<P = any, R = any> = {
  params: Param<P>
  response: Promise<R>
}

// the generic function type for my queries
type Query<P = any, R = any> = 
  <T extends QueryType<P,R>>( params: Pick<T, 'params'>['params'] ) => Pick<T, 'response'>['response']

For the first case query1() I do not really need to exend anything. I only need to apply the param and response types, eg

interface Input {
 param1: string,
 param2: number,
 param3: boolean
}
type QuerySomething = QueryType<Input, string>

class Q1 {
  private query1: Query;

  // just for completeness - I do not wanna type out `query1` here
  constructor( query1: Query ) {
    this.query1 = query1;
  }

  // and I would use this somehow like
  public async querySomething(input: Input): Promise<string> {
    const res = await this.query1<QuerySomething>( input );
    return res;
  } 
}

For the second case I simply can't figure out how to extend type Query that it will accept a parameter query: string in addition to the params object, without rewriting type Query. So

interface Input {
 param1: string,
 param2: number,
 param3: boolean
}

// I guess I have to do something here?
type QuerySomething = QueryType<Input, string>

class Q2 {
  private query2: Query;

  ...

  public async querySomething(query: string, params: Input): Promise<string> {
    const res = await this.query2<QuerySomething>( query, params );
    return res;
  } 
}

What I could do of course

type Query2<P = any, R = any> = 
  <T extends QueryType<P,R>>( query:string, params: Pick<T, 'params'>['params'] ) => Pick<T, 'response'>['response']

I hope this makes sense! I am still really confused with Typescript and I apologise if this is complete nonsense here!

Thanks!

CodePudding user response:

One thing that may help is to take a step back and try to describe a base abstract class that both Q classes should implement, you might come up with something like this:

abstract class Q_Base {
  public abstract querySomething(...args: any[]): Promise<any>
}

However we obviously want the type of arguments and return type to be constrained, we might consider taking a generic that is just the entire call signature of that method:

abstract class Q_Base2<Query_signature extends (...args:any[])=>Promise<any>> {
  public abstract querySomething(...args:Parameters<Query_signature>): ReturnType<Query_signature>;
}
interface Q1Input {
  // I assume this is well defined for you
}
class Q1 extends Q_Base2<(input: Q1Input)=>Promise<string>>{
  public async querySomething(input: Q1Input): Promise<string>{
    return ""
  }
}

Since this type has to be unwrapped with Parameters and ReturnType using a single generic for the function type is a little silly (if you were using arrow functions you could use public abstract querySomething: Query_Signature but that is up to you) so instead perhaps we could use 2 generics to represent the arguments and return type seperately:


abstract class Q_Base3<Params extends any[], R extends Promise<any>> {
  // could also leave no retriction on R and return type Promise<R> depending on which semantics are easier for you
  public abstract querySomething(...args: Params): R
}
class Q1_3 extends Q_Base3<[input: Q1Input], Promise<string>>{
  // this was filled in with the quick fix of Q1_3 does not implement necessary abstract methods
  public querySomething(input: Q1Input): Promise<string> {
    throw new Error("Method not implemented.");
  }
}

Either of those will likely work for you, but I think the root aid here is the idea that for polymorphism you can define one type that multiple implementations conform to, even with a generic there is some sense that Q1 and Q2 have similar behaviour and you can try to capture that in a base class, whether or not you actually use that base class. (it is just as valid to define the generics on Q1 and Q2 respectively)

CodePudding user response:

After trying out multiple different approaches over the last couple of days I have a (kind of) satisfying solution for my problem. I wan't to thank @Tadhg McDonald-Jensen for taking time and pushing me into the right direction!

So, I have changed and simplified the generic type definitions

// the generic object type
type Param<T> = { [key in keyof T]: T[key] }

// the generic query type
type QueryType<P, R> = {
  params: Param<P>
  response: Promise<R>
}

// generic typecast 
type TypeCast<T> = Param<T>[keyof T]

// the generic query type
type Query<P = any, R = any> = 
    <T extends QueryType<P, R>>( ...args: TypeCast<T['params']>[] ) => T['response']

While type Param and QueryType remain the same Query has changed to accept multiple arguments and I have added an additional type TypeCast, which simply returns the type of the parameter. So if we go back to the original example we would get

// the input parameters
interface Input {
  param1: string
  param2: number
  param3: boolean
}

// the type which we want to apply to the query function
type QuerySomething = QueryType<{ query: string, input: Input }, string>

// testing
type test = TypeCast<QuerySomething['params']>

// resolves to type test = string | Input

Applying this to the query function will give the desired results

I probably should mention, that my initial idea was not to have the same class method names querySomething in each class. This methods just represent a superset of many different class methods. The distinction between the 2 classes has been implemented since, query1 represents the fetch method from one external API endpoint and query2 another API endpoint.
type QuerySomething = QueryType<{ input: Input }, string>
type QuerySomethingElse = QueryType<{ query: string, input: Input }, string>

class Q {
  // just for simplification, these methods would be in different classes
  private query1: Query;
  private query2: Query;

  constructor( query1: Query ) {
    this.query1 = query1;
    this.query2 = query2;  
  }

  public async querySomething( query: string, input: Input ): Promise<string> {
    const res = await this.query1<QuerySomething>( input );
    return res;
  }

  public async querySomethingElse( query: string, input: Input ): Promise<string> {
    const res = await this.query2<QuerySomethingElse>( query, input );
    return res;
  } 
}

This will resolve to

// Typechecks
const res = await this.query1<QuerySomething>( input );            // OK
const res = await this.query1<QuerySomething>( query );            // ERROR -> OK

const res = await this.query2<QuerySomethingElse>( query, input ); // OK
const res = await this.query2<QuerySomethingElse>( input, query ); // ERROR -> OK

// Argument checks
const res = await this.query1<QuerySomething>();                   // OK -> should be ERROR
const res = await this.query1<QuerySomething>( input, input );     // OK -> should be ERROR

const res = await this.query2<QuerySomethingElse>( query, input, query ); // OK -> should be ERROR
const res = await this.query2<QuerySomethingElse>( query, input, input ); // OK -> should be ERROR

So basically, it does the right thing but by type definition does not take care about the number of arguments. If any of the Typescript wizards knows how to do this, I'd be happy to hear their solution! For now I think this is the best I can do.

If anyone is interested, I have updated the TS Playground.

  • Related