skip to Main Content

I’m trying to create a strict mapping for a theme, but I want the keys of the mapping, not to be dynamic, but have optional values for the keys. Meaning the key type of "100 | AppBackground" can have one of those keys and the same value. Here are some of the things I have tried. I always want to allow strict key value pairs like red:color.

type 300 = '300' | 'CardBackground';
export type ThemeMapping = {
  [key: '100' | 'AppBackground']: Color;
  [key in '200' | 'ContentBackground']: Color;
  [key: 300 ]: Color;
  ['400' | 'InactiveBackground']: Color;
  link: Color;
  red: Color;
  yellow: Color;
  green: Color;
  blue: Color;
}

I know for dynamic values you can also do {[key: string]: Color}. I just want the key type to essentially be options rather than dynamic or just a regular string.

Please let me know if you need me to explain more.

2

Answers


  1. You can combine multiple types to achieve this behavior.

    type MyFirstType = "100" | "AppBackground"
    
    type MySecondType = "200" | "ContentBackground"
    
    type CombinedType = MyFirstType | MySecondType | "red" | "link"
    
    type ThemeMapping = Record<CombinedType, Color>
    

    Is this what you want?

    Login or Signup to reply.
  2. TypeScript doesn’t natively support the concept of "an object with exactly one property from some set", so if you want to emulate it you’ll need to write something that works that way yourself. Here’s one approach:

    type ExactlyOneProp<T> = {
        [K in keyof T]-?: Pick<T, K> & { [P in Exclude<keyof T, K>]?: never }
    }[keyof T];
    

    The idea is that ExactlyOneProp<T> should result in a union type with one member for each property of T, where each union member should require on key and prohibit the others. TypeScript also can’t exactly prohibit a key; instead you can make an optional property whose value is the impossible never type, so the only acceptable thing to do is leave the property out (or maybe set it to undefined depending on compiler options). So ExactlyOneProp<{a: 0, b: 1, c: 2}> should be equivalent to {a: 0, b?: never, c?: never} | {a?: never, b: 1, c?: never} | {a?: never, b?: never, c: 2}.

    ExactlyOneProp<T> works by mapping over the each property key K in T, and making a new property of type Pick<T, K> & {[P in Exclude<keyof T, K>]?: never} (using the Pick<T, K> utility type to be an object with just a known K property) and then intersecting it with another mapped type with the keys in Exclude<keyof T, K> (using the Exclude<X, Y> utility type to filter out K from the keys of T) with all optional properties (from the ? mapping modifier) of type never.


    That produces a fairly ugly type, so we will prettify it via

    type Pretty<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
    

    which essentially just collapses all object intersections to their equivalent single-object types (e.g., {a: 0} & {b: 1} becomes {a: 0, b: 1}.

    Let’s test that:

    type Test = Pretty<ExactlyOneProp<{a: 0, b: 1, c: 2}>>;
    /* type Test = 
       { a: 0; b?: undefined; c?: undefined; } | 
       { b: 1; a?: undefined; c?: undefined; } | 
       { c: 2; a?: undefined; b?: undefined; };
    */
    

    Looks good.


    So now to make ThemeMapping the way you’d like, we need to intersect a bunch of different pieces, like this:

    type ThemeMapping = Pretty<
        ExactlyOneProp<{ 100: Color, AppBackground: Color }> &
        ExactlyOneProp<{ 200: Color, ContentBackground: Color }> &
        ExactlyOneProp<{ 300: Color, CardBackground: Color }> &
        ExactlyOneProp<{ 400: Color, InteractiveBackground: Color }> &
        { link: Color; red: Color; yellow: Color; green: Color; blue: Color }
    >;
    

    That becomes a union of 2×2×2×2×1 = 16 members, each of which has 2+2+2+2+5 = 13 properties, so displaying it all at once is too much to do here. It looks like:

    type ThemeMapping = {
        100: Color; AppBackground?: undefined;
        200: Color; ContentBackground?: undefined;
        300: Color; CardBackground?: undefined;
        400: Color; InteractiveBackground?: undefined;
        link: Color; red: Color; yellow: Color; green: Color; blue: Color;
    } | {
        100: Color; AppBackground?: undefined;
        200: Color; ContentBackground?: undefined;
        300: Color; CardBackground?: undefined;
        InteractiveBackground: Color; 400?: undefined;
        link: Color; red: Color; yellow: Color; green: Color; blue: Color;
    } | ⋯;
    

    You can verify that it behaves as desired by trying out different possibilities:

    declare const c: Color;
    const base = { link: c, red: c, yellow: c, green: c, blue: c };
    let t: ThemeMapping;
    t = { ...base, "100": c, "200": c, "300": c, "400": c }; // okay
    t = { ...base, "100": c, "200": c, "CardBackground": c, "400": c }; // okay
    t = { ...base, "100": c, "200": c, "400": c }; // error, missing prop
    t = { ...base, "100": c, "200": c, "300": c, 
      "CardBackground": c, "400": c }; // error, extra prop
    

    As you wanted, you have to define exactly one of the 300 and CardBackground propertes, and you’ll get an error if you try to define both or neither. And you can test the other properties yourself if you want.

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search