Anton Karpov
Anton Karpov

Reputation: 710

typescript - how to create type that is build based on arguments parametrization

I'm trying to create some extensible query parser for my project. It should parse incoming query string and return typed object. Also It should get typesd object and return string.

Lets imagine that I have my parametrized handler QueryParamHandler for each single parameter which is

class QueryParamHandler<T> {
    parse(v: string): T;
    stringify(v: T): string;
}

Then I have set of handlers for each type I want

const stringParser: QueryParamHandler<string> = ...;
const numberParser: QueryParamHandler<number> = ...;
const booleanParser: QueryParamHandler<boolean> = ...;
const dateParser: QueryParamHandler<Date> = ...;

now I want to create wrapper that can parse whole set of parameters depending on set of parsers that I provide in constructor

class MyCoolHandler<...> {
    constructor<TP>(handlers: TP extends Record<string, QueryParamHandler<any>>) {}
    parse(query: string): TV? {}
    stringify(vals: TV?): string {}
}

so how should I describe type TV (where all values can be undefined) to force typescript to check it based on passed handlers? I will describe desired behavior in following example:

const handler = new MyCoolHandler({ str: stringParser, from: dateParser });
handler.stringify({});             // ok
handler.stringify({ str: 'qqq' }); // ok
handler.stringify({ numb: 3 });    // TS error, `numb` key is not allowed here
handler.stringify({ from: 'qq' }); // TS error, `from` key should be Date type

const params = handler.parse('str=&from=2021-09-07')
console.log(params.str)   // ''
console.log(params.numb)  // TS error, params doesn't have 'numb' key

Upvotes: 0

Views: 502

Answers (2)

Nikita Ivanov
Nikita Ivanov

Reputation: 856

You can use a mapping type to create a proper type for your handlers param:

constructor(handlers: {
    [key in keyof TP]: QueryParamHandler<TP[key]>;
}) {}

This type describes an object that has all the properties that TP has, but redefines types of values in these properties to be QueryParamHandler<TP[key]> (where key is the current property name being iterated over). Now if you do this:

interface SomeObject {
    prop1: string;
    prop2: number;
}

const someHandler = new MyCoolHandler<SomeObject>({});

the compiler will complain that prop1 and prop2 are missing in the constructor, and your IDE will give you proper autocomplete on them.

Now you can just type your class methods like this:

class MyCoolHandler<TP extends object> {
    constructor(handlers: {
        [key in keyof TP]: QueryParamHandler<TP[key]>;
    }) {}

    parse(query: string): TP;
    stringify(obj: TP): string;
}

And now someHandler.stringify will only ever accept objects of type SomeObject.

Here's the complete example.

Upvotes: 1

Let's apply some constraints for our class.

class MyCoolHandler<
  ParserType,
  Parser extends QueryParamHandler<ParserType>,
  T extends Record<PropertyKey, Parser>
  > {
  constructor(handlers: T) { }
  parse(query: string) { }
  // stringify will be implemented in a moment
}

const stringParser = new QueryParamHandler<string>()
const dateParser = new QueryParamHandler<Date>()


const handler = new MyCoolHandler({ str: stringParser, from: dateParser });

If you hover your mouse on handler you will see that TS has infered all parsers correctly.

Now we can implement stringify method.

I assume that stringify should accept either str parser or from parser and not both. Let's implement Either utility:

type QueryParam<T extends QueryParamHandler<any>> =
  T extends QueryParamHandler<infer Param> ? Param : never
{
  // string
  type Test = QueryParam<QueryParamHandler<string>>
}

type Values<T> = T[keyof T]
{
  // 42 | 43
  type Test = Values<{ a: 42, b: 43 }>
}

type Either<T extends Record<string, any>> =
  Values<{
    [Prop in keyof T]: Record<Prop, QueryParam<T[Prop]>>
  }>
{
  // Record<"str", string> | Record<"from", Date>
  type Test = Either<{
    str: QueryParamHandler<string>;
    from: QueryParamHandler<Date>;
  }>
}

Now, when we have all set, let's take a look what we have:

class QueryParamHandler<T> {
  parse(v: T): T;
  stringify(v: T): string;
}


type QueryParam<T extends QueryParamHandler<any>> =
  T extends QueryParamHandler<infer Param> ? Param : never
{
  // string
  type Test = QueryParam<QueryParamHandler<string>>
}

type Values<T> = T[keyof T]
{
  // 42 | 43
  type Test = Values<{ a: 42, b: 43 }>
}

type Either<T extends Record<string, any>> =
  Values<{
    [Prop in keyof T]: Record<Prop, QueryParam<T[Prop]>>
  }>
{
  // Record<"str", string> | Record<"from", Date>
  type Test = Either<{
    str: QueryParamHandler<string>;
    from: QueryParamHandler<Date>;
  }>
}

class MyCoolHandler<
  ParserType,
  Parser extends QueryParamHandler<ParserType>,
  T extends Record<PropertyKey, Parser>
  > {
  constructor(handlers: T) { }
  parse(query: string):T { return null as any }
  stringify(vals: Partial<Either<T>>):string { return null as any }
}

const stringParser = new QueryParamHandler<string>()
const dateParser = new QueryParamHandler<Date>()


const handler = new MyCoolHandler({ str: stringParser, from: dateParser });
handler.stringify({});             // ok
handler.stringify({ str: 'qqq' }); // ok
handler.stringify({ from: new Date() }); // ok

// {
//     str: QueryParamHandler<string>;
//     from: QueryParamHandler<Date>;
// }
const obj = handler.parse('sdf')

handler.stringify({ numb: 3 });    // TS error, `numb` key is not allowed here
handler.stringify({ from: 'qq' }); // TS error, `from` key should be Date type

Playground

As for the parse method. I'm not sure what you expect. DO you want to validate parse argument like here:

  parse<T extends string>(query: T extends `str${string}from${string}` ? T : never) { }

and infer from template literal type an object with appropriate keys and values?

Upvotes: 1

Related Questions