0

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
2
  • 1
    Pls share reproducable example. Where did you get TP and TV. Please get rid of syntax errors Commented Sep 6, 2021 at 9:33
  • 1
    TP is what we get in constructor. TV is what I'm looking for to get the result from last code example Commented Sep 6, 2021 at 9:41

2 Answers 2

1

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.

Sign up to request clarification or add additional context in comments.

3 Comments

thanks, I've also implemented this very similar The only problem is that we have to set TP manually I thought that TS somehow can get it from 'handlers' type so you just to need to const someHandler = new MyCoolHandler({ st: stringParser }); and you'll get output type of someHandler.parse('') => { str?: string} because stringParser gives us string
It is possible; i've just realized that my playground link is completely broken, so I dropped an updated one without explicit <SomeObject> specification – it still works fine
yep, but in this case all keys in TP will be mandatory but to make them optional we can just return Partial<T> from parse thanks!
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?

3 Comments

no, you got it wrong stringify gets object with values that should be stringified. Each value is stringified by parser that is passed for this value in constructor. ('parse' is reverse operation) const h = new MyCoolHandler({ str: stringParser, from: dateParser }); h.stringify({ str: 'qqq', from: new Date() }); // ok h.parse('.') // ok => { str?: string; from?: Date } And I want to have strict typings for 'stringify' argument and 'parse' output check the previous answer. it is doing what I want but I have to set values type. (acceptably, but I'm curious if I can get rid of it)
but in your example Either type depends on specific handlers which means that I won't be able to use MyCoolHandler for any parameters sets
please share an example

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.