nas.engineer
nas.engineer

Reputation: 344

How to infer types from array of string values in TypeScript?

Aim

  1. I'd like for TS to infer the types based on the values (static) passed to an array of strings.
  2. The actual function implementation should give the correct result aswell

Problem

Create a function, called:

export function CreateColoursPalette(tints, palette) {
  //...
}

Usage

const tints = {
  sun: createColour({
    10: '#FEF0D6',
    30: '#FCE0AC',
    50: '#FBD183',
    80: '#F8BA44',
    100: '#F7AB1B',
  }),
  azure: createColour<110>({
    10: '#FEF0D6',
    30: '#FCE0AC',
    50: '#FBD183',
    80: '#F8BA44',
    100: '#F7AB1B',
    110: '#FFE0AC',
  })
};


const colours = CreateColoursPalette(tints, {
  primary: ['sun', 'azure'],
  secondary: ['sun'],
  tertiary: {
    neutral: ['sun', 'azure'],
  },
});
  1. tints is an object which holds all the available tints you have
  2. palette is an object which holds an array of string values, which are keys from tints object

The function should transform the palette object, by replacing each string key in the array with its respective value from the tints object.

IntelliSense

When we call colours, TS should infer correct types, i.e.

colours.
     //| primary
     //| secondary
     //| tertiary

// here TS should infer only 3 possible options due to the array above
colours.primary.
             //| sun
             //| azure

const [baseColour, tints] = colours.primary.sun;
tints.
   //| 10 | 30 | 50 | 80 | 100

colours.secondary.
               //| sun

colours.tertiary.
              //| neutral

const [baseColour, tints] = colours.tertiary.neutral.azure
tints.
   //| 10 | 30 | 50 | 80 | 100 | 110

My current solution (with wrong TS typing)

To make this question less intimidating, I've put my solution into the Typescript playground.

I'm struggling to make TS infer types based on the values in my array.

Upvotes: 1

Views: 1978

Answers (1)

jcalz
jcalz

Reputation: 330466

For the typings, it looks like you want this:

function CreateColoursPalette<T, P extends Palette<keyof T>>(
  tints: T, palette: P): ToColoursPalette<T, P>;

where Palette<K> and ToColoursPalette<T, P> are defined like this:

type Palette<K extends PropertyKey> = {
  [k: string]: Array<K> | Palette<K>;
}

type ToColoursPalette<T, P> = {
  [K in keyof P]: (P[K] extends Array<any> ?
    { [L in P[K][number]]: [baseColour: BaseColour, tints: T[L]] } :
    ToColoursPalette<T, P[K]>
  ) };

A Palette<K> is an object whose property values are either an array of elements in K, or another Palette<K>. This is a recursive definition which should allow you to pass in nested palettes.

ToColoursPalette<T, P> represents the desired transformation of the palette type P. It is also recursive. Let's walk through it: {[K in keyof P]: ...} means that it is a mapped type where the output value will have the same keys as P. For each such key K, we check it to see if it's an array: P[K] extends Array<any> ? . If not, then we are recursing down, and we want the property value at key K to be ToColoursPalette<T, P[K]>. If so, then we want to take the elements of that array (which is P[K][number], the type you get if you index into P[K] with a numeric key) and make a new object type with those elements as keys. For each such key L from P[K][number], we make the property value a two-element tuple. The first element is a BaseColour, whose type and details are not what you're asking about. The second element is of type T[L], which is what you get when you look up the property named L of the tints object type T.


The implementation of CreateColoursPalette() in JavaScript should look something like:

function CreateColoursPalette(tints, palette) {
  return Object.fromEntries(Object.entries(palette).map(([k, v]) => {
    if (Array.isArray(v)) {
      return [k, Object.fromEntries(v.map(p => [p, [baseColour, tints[p]]]))];
    } else {
      return [k, CreateColoursPalette(tints, v)];
    }
  }));
}

I'm using Object.entries() and Object.fromEntries() to transform objects to arrays and vice versa.

The JS code is doing something quite similar to the typing; we are transforming palette into a new object with the same keys k and whose output values depend on whether or not the input values v are arrays. If v is not an array, then we recurse down and call CreateColoursPalette(tints, v). Otherwise, we want to produce an object whose keys are the elements of v, and whose values are a two-tuple whose first element is some baseColour thing we're not concerned with, and whose second element is tints[p].


This is essentially the whole thing, except that there is no great way to have the compiler look at the JavaScript code and verify that it conforms to the TypeScript typings. If you try, you will tend to run into compiler errors. To turn the JS implementation into something TS accepts, it will be easiest to just use type assertions or the any type or the like to loosen/disable type checking of the implementation. Essentially we are taking the responsibility onto ourselves for ensuring that the the implementation and the typings are compatible.

It could look like this:

function CreateColoursPalette<T, P extends Palette<keyof T>>(
  tints: T, palette: P): ToColoursPalette<T, P>;
function CreateColoursPalette(tints: any, palette: any): any {
  return Object.fromEntries(Object.entries(palette).map(([k, v]: [string, any]) => {
    if (Array.isArray(v)) {
      return [k, Object.fromEntries(v.map(p => [p, [baseColour, tints[p]]]))];
    } else {
      return [k, CreateColoursPalette(tints, v)];
    }
  }));
}

I'm using a single-call-signature overloaded function to declare the typings, and an intentionally loose any-riddled implementation. There's no compiler error.


We can now verify that it behaves as desired:

console.log(colours.primary.azure[1]["110"]) // "#FFE0AC"
console.log(colours.tertiary.neutral.sun[1]["10"]) // "#FEF0D6"

IntelliSense prompts with the right keys along the way, and the console log output demonstrates that the implementation is also doing what we expect. Looks good!


Playground link to code

Upvotes: 2

Related Questions