5

As a novice typescript user, I am having trouble even formulating the question, so please bear with me.

I am trying to create a key => [string + valueObject interface] map of strings and valueObjects (as a type) and then have a function, which enforces the valueObject interface, based on the passed key.

I feel it's best explained by an example:

// This is an pseudo example stub, not actually working

type ReplaceableWith<T> = string;
//                   ^ the type I'd like to enforce as the argument

const templates = {
  // templateId    // template               // define somehow the interface required for this template
  'animal.sound': 'A {animal} goes {sound}' as ReplaceableWith<{ animal: string; sound: string}>
};

function renderTemplate(
  templateId , // must be a key of templates
  params // must match value object type, based on templateId
): string {
  let rendered = templates[templateId];
  for (const [key, value] of Object.entries(params)) {
    // replace keys from template with values
    rendered = rendered.replace('{' + key + '}', value);
  }
  return rendered;
}

const a = renderTemplate('animal.sound', { animal: 'Dog', sound: 'woof' })
//    ^ a = 'A Dog goes woof'
const b = renderTemplate('animal.sound', { name: 'Some' });
//                                         ^ should throw TS error

Obviously, this example does not work, but I think it demonstrates what I am trying to acheive. I have made some uneducated attempts with keyof, generics and enums without success.

Is this sort of type mapping (or lookup) even possible?


Update (working example)

After some playing around, here's a working example with a potential solution:

type TemplateKeys = {
  'animal.sound': { animal: string; sound: string };
  'animal.sleep': { location: string };
  'animal.herd': { expectedCount: number; available: number };
  'animal.think': undefined;
};

const templates: {[key in keyof TemplateKeys]: string} = {
  'animal.sound': '{animal} goes {sound}',
  'animal.sleep': 'It sleeps in {location}',
  'animal.herd': 'There is {available} animals out of {expectedCount}',
  'animal.think': 'Its thinking'
};

function renderTemplate<K extends keyof TemplateKeys>(key: K, params?: TemplateKeys[K]): string {
  if (params !== undefined) {
    //@ts-ignore
    return Object.entries(params).reduce((previousValue: string, [param, value]: [string, any]) => {
      return previousValue.replace('{' + param + '}', value);
    }, templates[key]);
  }
  return templates[key];
}

console.log(renderTemplate('animal.sound', { animal: 'Dog', sound: 'woof' }));
console.log(renderTemplate('animal.sleep', { location: 'a hut' }));
console.log(renderTemplate('animal.herd', { expectedCount: 20, available: 10 }));
console.log(renderTemplate('animal.think'));

Outputs:

[LOG]: Dog goes woof 
[LOG]: It sleeps in a hut 
[LOG]: There is 10 animals out of 20 
[LOG]: Its thinking 

Although this works, it has two issues:

  1. I have to define keys twice (in the interface and the instance).
  2. The parameters interface and the message are separated, ideally they should be together.
Vanja D.
  • 834
  • 13
  • 20

1 Answers1

1

September 17 2021 update

I have removed any repetitive code.

Also I have created a union of allowed parameters - Params. Please see updated example

const enum Animal {
  sound = 'animal.sound',
  sleep = 'animal.sleep',
  herd = 'animal.herd',
  think = 'animal.think',
}

type TemplateKeys = {
  [Animal.sound]: { animal: string; sound: string };
  [Animal.sleep]: { location: string };
  [Animal.herd]: { expectedCount: number; available: number };
  [Animal.think]?: string;
};

const templates = {
  [Animal.sound]: '{animal} goes {sound}',
  [Animal.sleep]: 'It sleeps in {location}',
  [Animal.herd]: 'There is {available} animals out of {expectedCount}',
  [Animal.think]: 'Its thinking'
} as const;

type Templates = typeof templates;

type Values<T> = T[keyof T]

type Dictionary = {
  [Prop in keyof TemplateKeys]:
  (key: Prop, params: TemplateKeys[Prop]) => string
}

type Params =
  Parameters<
    NonNullable<
      Values<Dictionary>
    >
  >

const renderTemplate = (...params: Params) => {
  const [key, ...props] = params;
  if (props !== undefined) {
    // @ts-ignore
    const result = (Object.entries(props) as Array<Params>)
      .reduce<string>((previousValue, [param, value]) =>
        typeof value === 'string'
          ? previousValue.replace('{' + param + '}', value)
          : previousValue,
        templates[key]
      );

    return result


  }
  return templates[key];
}


renderTemplate(Animal.sleep, { location: 'a hut' }) // ok

renderTemplate(Animal.herd, { expectedCount: 20, available: 10 }) // ok
renderTemplate(Animal.sound, { location: 'sd' }) // expected error

I've created Animal enum for keys. Don't worry, enum will not be included in your bundle.

Here is the link to payground Here is the link for Object.entries typings

Explanation:

Dictionary - the main utility type. It creates an object with Animal keys and a function as a value. THis function already takes into account allowed props for particular key.

Example:

type Dictionary = {
    "animal.sound": (key: Animal.sound, params: {
        animal: string;
        sound: string;
    }) => string;
    "animal.sleep": (key: Animal.sleep, params: {
        location: string;
    }) => string;
    "animal.herd": (key: Animal.herd, params: {
        expectedCount: number;
        available: number;
    }) => string;
    "animal.think"?: ((key: Animal.think, params: string | undefined) => string) | undefined;
}

Now, we need to convert this data structure to tuple with two elements. First element - a key, second elements - is allowed argument.

You may ask: Why we need a tuple? Because rest parameters is a tuple.

So we need only to take all values with help of Values utility and obtain their parameters with help of Parameters built in utility:

type Params =
  | [Animal.sound, {
    animal: string;
    sound: string;
  }]
  | [Animal.sleep, {
    location: string;
  }]
  | [Animal.herd, {
    expectedCount: number;
    available: number;
  }]
  | [Animal.think, string | undefined]

type Params =
  Parameters<
    NonNullable<
      Values<Dictionary>
    >
  >