#37933Medium

Theme Colors

Turn a nested Tailwind-style color config into a union of color names, where DEFAULT keys collapse to the bare parent name and variants get a dash.

Every design system eventually grows a COLORS object, and every autocomplete for it eventually goes stale. This challenge fixes that by deriving the color names from the config itself.

You get a Tailwind-style configuration where a value is either a color string or a nested object of variants. Flat entries contribute their key as-is. Nested entries contribute the parent key for DEFAULT, and parent-variant for everything else. Your job is to produce the union of every valid name.

const COLORS = {
  border: 'hsl(var(--some-color))',
 
  primary: {
    DEFAULT: 'hsl(var(--some-color))',
    dark: 'hsl(var(--some-color))',
    light: 'hsl(var(--some-color))',
    foreground: 'hsl(var(--some-color))',
  },
 
  muted: {
    DEFAULT: 'hsl(var(--some-color))',
    foreground: 'hsl(var(--some-color))',
  },
}
 
type AppColor = ExtractColors<typeof COLORS>
// Result: 'border' | 'primary' | 'primary-dark' | 'primary-light' |
//         'primary-foreground' | 'muted' | 'muted-foreground'

Challenge Instructions: Theme Colors

Medium

Given a Tailwind-style color configuration object, extract all possible color names that can be used in your application. The type should handle both simple string values and nested objects with DEFAULT and variant keys.

For nested objects:

  • DEFAULT key should use just the parent key name
  • Other keys should be combined with the parent key using a dash separator

Use only the COLORS object to create your color type.

For example:

const COLORS = {
border: 'hsl(var(--some-color))',
 
primary: {
DEFAULT: 'hsl(var(--some-color))',
dark: 'hsl(var(--some-color))',
light: 'hsl(var(--some-color))',
foreground: 'hsl(var(--some-color))',
},
 
muted: {
DEFAULT: 'hsl(var(--some-color))',
foreground: 'hsl(var(--some-color))',
},
}
 
type AppColor = ExtractColors<typeof COLORS>
// Result: "border" | "primary" | "primary-dark" | "primary-light" |
//         "primary-foreground" | "muted" | "muted-foreground"

View on GitHub: https://tsch.js.org/37933

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

const COLORS = {
  border: 'hsl(var(--some-color))',

  input: 'hsl(var(--some-color))',

  ring: 'hsl(var(--some-color))',

  background: 'hsl(var(--some-color))',

  foreground: {
    DEFAULT: 'hsl(var(--some-color))',
    light: 'hsl(var(--some-color))',
    title: 'hsl(var(--some-color))',
    title2: 'hsl(var(--some-color))',
  },

  primary: {
    DEFAULT: 'hsl(var(--some-color))',
    dark: 'hsl(var(--some-color))',
    light: 'hsl(var(--some-color))',
    foreground: 'hsl(var(--some-color))',
  },

  secondary: {
    DEFAULT: 'hsl(var(-

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

The solution, in two types:

type VariantSuffix<V extends string> = V extends 'DEFAULT' ? '' : `-${V}`
 
type ExtractColors<T> = {
  [K in keyof T & string]: T[K] extends string
    ? K
    : `${K}${VariantSuffix<keyof T[K] & string>}`
}[keyof T & string]
 
type AppColor = ExtractColors<typeof COLORS>

Starting from the value

COLORS is a value, not a type, so typeof COLORS is what crosses over. Note that the config has no as const, which means the string literals widen:

// typeof COLORS is roughly
// {
//   border: string
//   muted: { DEFAULT: string; foreground: string }
//   ...
// }

That widening is convenient here. The color strings carry no information we want, so T[K] extends string becomes a clean test for "is this a flat entry or a group of variants".

A mapped type that builds values, then throws away the keys

ExtractColors maps over the keys of T and stores a string in each slot:

// the mapped type alone, before the indexed access
// {
//   border: 'border'
//   muted: 'muted' | 'muted-foreground'
//   primary: 'primary' | 'primary-dark' | 'primary-light' | 'primary-foreground'
// }

Appending [keyof T & string] to a mapped type indexes it with every key at once, and indexing with a union yields the union of the results. The object is scaffolding: it exists only so that the indexed access can flatten it into 'border' | 'muted' | 'muted-foreground' | ....

The & string appears twice for the same reason. keyof T can contain number and symbol, and a mapped-type key or a template literal slot needs something a string can hold. Intersecting with string drops the rest.

Where the dash comes from

VariantSuffix is a naked conditional over a type parameter, so it distributes: TypeScript applies it to each member of the union separately and unions the answers.

type S = VariantSuffix<'DEFAULT' | 'dark' | 'foreground'>
// '' | '-dark' | '-foreground'

DEFAULT maps to the empty string, every other variant gains a leading dash. Feed that back into the template literal and the interpolation distributes too, pairing the parent key with each suffix:

type M = `muted${'' | '-foreground'}`
// 'muted' | 'muted-foreground'

So one branch of the conditional handles the parent name and the variant names in a single expression. Without the distribution you would need a separate union member for the DEFAULT case and an Exclude<..., 'DEFAULT'> for the rest, which works but says the same thing twice.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges