Reputation: 710
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
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
Reputation: 33101
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
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