Reputation: 2688
I'd like to learn how to use generics more effectively, and so wanted to try to refactor a piece of code that is currently verbose and duplicated.
Currently I have this:
interface FooData {
foo: string;
}
function renderFoo (data: FooData): string {
return templateEngine.render("./foo.template", data)
}
interface SpamData {
spam: number;
}
function renderSpam (data: SpamData): string {
return templateEngine.render("./spam.template", data)
}
The call to templateEngine.render
naively combines template-path and data without type-checking, I'm looking to build type-safety on top of that.
The above code works and does ensure that e.g. spam.template
is only rendered with data of type SpamData
, but the structure is verbose and duplicated.
I think there might be a solution that offers a single function to call (e.g. renderTemplate
) that has a signature that (somehow?) enforces the shape of data
based on the chosen template. But I'm too new to types to understand what I'm asking or indeed how to do it.
My question is: How might this be refactored? I'm also open for broad feedback if it sounds like I'm fundamentally barking up the wrong tree, your thoughts are appreciated.
Upvotes: 3
Views: 58
Reputation: 1014
The difficulty in your case is that we have to pass the path of the template in the function, what is not really convenient (as showed @lukasgeiter).
@jcalz proposed two good solutions, but be careful to some points:
discriminated union is a sexy pattern, but not always usable. In this case it's in a data type, but imagine this data come from a server, the kind
discrimate property may not be able to exist;
string manipulations are not safe, I suggest to use a map { [K in Kind]: TemplatePath; }
. String concatenation are by definition type unsafe, you may have an error when you use a not ordinary path, and the debug may be longer. By using a map you centralize the possible bug sources to an only one constant, what is far more maintanable.
My code suggestion:
interface FooData {
foo: string;
}
interface SpamData {
spam: number;
}
interface TemplateMap {
foo: FooData;
spam: SpamData;
}
type Kind = keyof TemplateMap;
const templateKindMap: Readonly<{ [K in Kind]: string }> = {
foo: './foo.template',
spam: './spam.template'
};
function render<K extends Kind>(kind: K, data: TemplateMap[K]): string {
return templateEngine.render(templateKindMap[kind], data);
}
render('foo', {foo: ''});
render('spam', {spam: 0});
Hope it helps a little.
Upvotes: 1
Reputation: 153150
First of all, let me say I'm not sure it makes sense to refactor this. Especially as the template is a file path. For TypeScript ./foo.template
and foo.template
are different, while for the template engine they might be the same thing. However, I'll leave it for you to decide whether you want to refactor or keep it as is.
Here are my two solutions for this problem:
Function overloads allow you to specify alternative method signatures in which we can specify the combinations of template and data interface:
function renderTemplate(template: './foo.template', data: FooData): string;
function renderTemplate(template: './spam.template', data: SpamData): string;
function renderTemplate(template: string, data: any): string {
return templateEngine.render(template, data);
}
renderTemplate("./unknown.template", {}); // error
renderTemplate("./foo.template", { spam: 42 }); // error
renderTemplate("./foo.template", { foo: 'bar' }); // no error
Alternatively we can leverage generics and lookup types to accomplish the same. It's a bit harder to read but less verbose than the function overloads.
First we need some kind of mapping between the template names and data interfaces. For this we'll use a new interface:
interface TemplateMap {
"./foo.template": FooData,
"./spam.template": SpamData
}
Now for the function we add a generic parameter T
for the template
parameter which is constrained to be property name from TemplateMap
. We do this by specifying T extends keyof TemplateMap
. Lastly the data
argument needs to match the corresponding type from TemplateMap
. We'll retrieve this type with TemplateMap[T]
.
function renderTemplate<T extends keyof TemplateMap>(template: T, data: TemplateMap[T]): string {
return templateEngine.render(template, data);
}
renderTemplate("./unknown.template", {}); // error
renderTemplate("./foo.template", { spam: 42 }); // error
renderTemplate("./foo.template", { foo: 'bar' }); // no error
Upvotes: 1
Reputation: 330481
You should either turn FooData | SpamData
into a discriminated union with a kind
or template
discriminant property, or you should pass in two arguments to renderTemplate
, the first one being something like a kind
or template
string. In either case you should choose some string literals to distinguish the data types. I will use "foo"
and "spam"
here. First, a discriminated union:
interface FooData {
kind: "foo";
foo: string;
}
interface SpamData {
kind: "spam";
spam: number;
}
type Data = FooData | SpamData;
function render(data: Data): string {
return templateEngine.render("./" + data.kind + ".template", data);
}
render({ kind: "foo", foo: "hey" }); // okay
render({ kind: "spam", spam: 123 }); // okay
render({ kind: "foo", spam: 999 }); // error!
You can see that Data
is a union of FooData
and SpamData
, each of which has a kind
property you can use to discriminate which type it is. It is fortuitous that you can build the template path with string manipulation, but if that doesn't work for you, you could set up a lookup table.
The two-argument approach would look like this:
interface FooData {
foo: string;
}
interface SpamData {
spam: number;
}
interface DataMap {
foo: FooData;
spam: SpamData;
}
function render<K extends keyof DataMap>(kind: K, data: DataMap[K]): string {
return templateEngine.render("./" + kind + ".template", data);
}
render("foo", { foo: "hey" }); // okay
render("spam", { spam: 123 }); // okay
render("foo", { spam: 999 }); // error!
Here we have come up with a mapping interface called DataMap
which represents the relationship between the kind
string and the data type. It's similar to the discriminated union, although I used a generic function to capture the constraint between arguments to render()
. The same point about string-manipulation-vs-lookup for actually calling templateEngine.render()
stands here too.
Hope that gives you some ideas. Good luck!
Upvotes: 3