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'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 nameUse 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).
/* _____________ 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(-Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.
Monthly subscription. Cancel anytime.
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>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".
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.
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.
hover has only a DEFAULT key. Its suffix union is just '', so it contributes 'hover' and no dangling 'hover-'.border, input, ring and background are flat strings and go straight through as their own key.foreground is both a top-level group and a variant name inside other groups. Nothing special is needed: 'foreground' comes from the group's DEFAULT, 'primary-foreground' from the variant, and the union absorbs both.title2 shows that digits survive template literal interpolation unchanged, giving 'foreground-title2'.ExtractColors to call itself on the nested branch.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges