Jon Lauridsen
Jon Lauridsen

Reputation: 2688

Use an argument or generic to constrain type of another argument?

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

Answers (3)

Richard Haddad
Richard Haddad

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

lukasgeiter
lukasgeiter

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

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

Playground

Generics

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

Playground

Upvotes: 1

jcalz
jcalz

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!

Link to code

Upvotes: 3

Related Questions