Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can a TypeScript interface require multiple string keys or values to be the same?

Tags:

typescript

Let's say I want to create an interface that looks something like this:

interface ColorPalette {
  defaultColorName: string // should exist in colors below
  colors: {
    [colorName: string]: string // associate a color name to a color (hex string)
  }
}

In other words, this color palette concept allows specifying a list of colors (where each can be accessed by an arbitrary color name), but also providing a default color.

I'm wondering if it is possible in TypeScript to enforce that the value of defaultColorName is present in the key/value object colors, as it would not make sense to specify a default color that does not exist in colors.

I know how this could work if the color names are a string union, for example type ColorType = 'bright' | 'dark' | 'subtle' where I could then use generics with the interface, for example interface ColorPalette<T extends ColorType>. In this case, the color names are arbitrary strings that could be anything.

I'm using the concept of a color palette to demonstrate what I want to do, my actual use case is not specific to colors.

like image 866
DivideByHero Avatar asked Oct 22 '25 04:10

DivideByHero


2 Answers

The key factor here is this:

In this case, the color names are arbitrary strings that could be anything.

Because of this stipulation, what you are asking for is not possible, because you are asking the TypeScript compiler (something that only exists at compile-time, surprisingly) to make decisions based on something that happens at runtime (inserting arbitrary strings key/value pairs into the colors object).

like image 134
Ben Wainwright Avatar answered Oct 23 '25 19:10

Ben Wainwright


You certainly can describe the constraint you're talking about in TypeScript, although not as a specific type. Instead, you can use generics (as you mention) along with helper functions so that generic type parameters can be inferred instead of manually specified.

While it is true that TypeScript's type system does not exist runtime, the idea of the type system is to describe sets of values that do exist at runtime. It's a bit of a red herring to point out that you don't know in advance what the color names will be. There's a utility type, Record<K, V>, whose immense usefulness is not lessened by the fact that I can't tell you right now which specific keys will be in K.

It might or might not be useful to strongly type ColorPalette to represent your constraint, depending on whether you think it's worth dragging generic type parameters around your TypeScript code. But that's different from saying that it's not useful because the type system is erased.


For example:

interface ColorPalette<K extends string> {
  colors: {
    [P in K]: string // associate a color name to a color (hex string)
  };
  defaultColorName: NoInfer<K> // should exist in colors above
}

type NoInfer<T> = [T][T extends any ? 0 : never]; // see microsoft/TypeScript#14829    

const asColorPalette = <K extends string>(
  colorPalette: ColorPalette<K>) => colorPalette;

Here we are making ColorPalette generic in K, the union of keys of the colors property. In principle you also want defaultColorName to be of type K, but this will make type inference less useful: ideally you want the compiler to use colors to infer the set of color names available, and then just check that defaultColorName is one of them. So we want defaultColorName to be K but not to use it for type inference: NoInfer<K>. There is currently no "official" way to do this; see microsoft/TypeScript#14829 for the relevant feature request. In that issue are several workarounds/implementations that work for some use cases. Above I'm using this one.

Okay, so ColorPalette<K> uses K for both the keys of colors and the value of defaultColorName, and when we infer K we will only use colors and not defaultColorName. Then we have the helper function asColorPalette() which can be used to turn an object literal into a ColorPalette<K> for a suitable K. If there are errors, it's because the constraint has been violated:

const okayColorPalette = asColorPalette({
  colors: {
    red: "#FF0000",
    green: "#00FF00",
    blue: "#0000FF"
  },
  defaultColorName: "red"
});

const badColorPalette = asColorPalette({
  colors: {
    red: "#FF0000",
    green: "#00FF00",
    blue: "#0000FF"
  },
  defaultColorName: "purple" // error!
  //~~~~~~~~~~~~~~ <-- "purple" is not assignable to "red" | "green" | "blue"
});

const differentColorPalette = asColorPalette({
  colors: {
    harvestGold: "#E6A817",
    avocado: "#568203",
    burntOrange: "#BF5700"
  },
  defaultColorName: "avocado"
});

Here the compiler accepts okayColorPalette and differentColorPalette but rejects badColorPalette. So if any TypeScript code ever gets written in which the color names are specified, the compiler will help you.


Even if you never actually see concrete color names in TypeScript code, the ColorPalette<K> type can still be of use. Presumably you want to write some TypeScript code to manipulate ColorPalette<K> values for some unknown K, right? For example:

function useColorPalette<K extends string>(colorPalette: ColorPalette<K>) {
  for (let k in colorPalette.colors) {
    console.log(k + "->" + colorPalette.colors[k].toUpperCase());
  }
  console.log(
    "The hex string corresponding to the default color is " +
    colorPalette.colors[colorPalette.defaultColorName].toUpperCase()
  );
 colorPalette.colors.aquamarine; // error!
 // Property 'aquamarine' does not exist on type '{ [P in K]: string; }'
}

The compiler knows a bit about colorPalette due to the generic typing: it knows that the for..in loop produces a k that can be used to index into the colors property; it knows that the defaultColorName property can be used as a key of the colors property; and it knows that some random string like "aquamarine" cannot necessarily be used as a key of the colors property. If you just used string instead of K extends string, the compiler would have allowed that aquamarine index.


Again, this might not be worth it to you. Generic types are more cumbersome to deal with than specific types. But it's not quite as bad as "don't bother because TypeScript goes away before you run anything".

Playground link to code

like image 31
jcalz Avatar answered Oct 23 '25 18:10

jcalz



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!