typescriptcolorshextypescript-genericscolor-codes

How to create standalone type for a HEX color string?


I'm trying to create a standalone type in TypeScript that can be used to represent a single valid HEX color code as a fully type-safe string.

My attempt is below, which falls short due to not actually being a standalone type, which is what I would hope to achieve.

type HexDigit<T extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'a' | 'b' | 'c' | 'd' | 'e'| 'f' | 'A' | 'B' | 'C' | 'D' | 'E'| 'F'> = T;
type HexColor<T extends string> =
    T extends `#${HexDigit<infer D1>}${HexDigit<infer D2>}${HexDigit<infer D3>}${HexDigit<infer D4>}${HexDigit<infer D5>}${HexDigit<infer D5>}`
        ? T
        : (
            T extends `#${HexDigit<infer D1>}${HexDigit<infer D2>}${HexDigit<infer D3>}`
            ? T
            : never
        );

function hex<T extends string>(s: HexColor<T>): T {
    return s;
}

// All valid
hex('#ffffff');
hex('#fff');
hex('#000');
hex('#123456');
hex('#abcdef');
hex('#012def');
hex('#ABCDEF');

TypeScript playground link

Trying to use a type with a generic as a standalone type was bound to fail, so I got stuck at this point.

// Ideal use case - does not compute
const color: HexColor = '#aaa';
const theme: Record<string, HexColor> = {
    backgroundColor: '#ff0000',
    color: '#0f0',
};

Is this even possible to achieve with TypeScript, and if so, how?


Solution

  • You probably already tried to do it the naive way, where you make a union of all the possibilites, and it does actually work for a three-digit hex color, but not for a longer one (see code below). I suspect, to answer your question directly, that it's impossible to make a 'standalone' type that works for all six-digit hex colors, because this would have to be a union of millions of elements.

    type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'a' | 'b' | 'c' | 'd' | 'e'| 'f' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F';
    type ShortColor = `#${Digit}${Digit}${Digit}`;
    type LongColor = `#${Digit}${Digit}${Digit}${Digit}${Digit}${Digit}`;  // Error
    type Color = ShortColor | LongColor;
    
    const someColor: ShortColor = '#fc2';
    const badColor: ShortColor = '#cg2';  // Error
    

    That said, I think your solution is a fine one itself. I did notice your code doesn't work on newer versions of TypeScript, so I made a slight modification to it so that it does work with the latest version, 4.4.0-beta. The code is a bit simpler and avoids generating too large of a union by doing the conditional check in two steps:

    type HexDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'a' | 'b' | 'c' | 'd' | 'e'| 'f' | 'A' | 'B' | 'C' | 'D' | 'E'| 'F';
    type HexColor<T extends string> =
        T extends `#${HexDigit}${HexDigit}${HexDigit}${infer Rest1}`
            ? (Rest1 extends `` 
                ? T // three-digit hex color
                : (
                    Rest1 extends `${HexDigit}${HexDigit}${HexDigit}`
                        ? T  // six-digit hex color
                        : never
                )
            )
            : never;
    
    function hex<T extends string>(s: HexColor<T>): T {
        return s;
    }
    
    // All valid
    hex('#ffffff');
    hex('#fff');
    hex('#000');
    hex('#123456');
    hex('#abcdef');
    hex('#012def');
    hex('#ABCDEF');
    

    Finally, I might suggest that you use type brands to create a type which signifies that a string is a valid hex color, and only use your hex function to create strings of that type? In the example below, you could simply use Color throughout your codebase and only need to rely on the hex helper function when specifically making a Color from a string literal for the first time. I don't know if this'll meet your exact ideal usecase, because you do still need to use your hex helper wherever a literal is declared, but it gets you pretty close:

    type Color = string & { __type: "HexColor" };
    function hex<T extends string>(s: HexColor<T>): Color {
        return s;
    }
    
    const color: Color = hex('#aaa');
    const theme: Record<string, Color> = {
        backgroundColor: hex('#ff0000'),
        color: hex('#0f0'),
    };