Extend the possible types of values of dictionary in TypeScript

1.5k views Asked by At

I have a strongly-typed style object in my React Native project in TypeScript:

const styles = StyleSheet.create({
  container:{
    backgroundColor: 'red',
    flex: 1
  },
});

This works as normal as StyleSheet.create expects arbitrary named keys with the target values of ViewStyle, TextStyle, and ImageStyle as defined below:

export namespace StyleSheet {
    type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };

    /**
     * Creates a StyleSheet style reference from the given object.
     */
    export function create<T extends NamedStyles<T> | NamedStyles<any>>(styles: T | NamedStyles<T>): T;
    [...]
}

container is an arbitrary key, and backgroundColor is defined in ViewStyle to have a value of the type ColorValue, which is defined as string | OpaqueColorValue where OpaqueColorValue is a unique symbol.

However, I'm building a function that I want to feed with the following type signature wherever ColorValue was previously accepted in original type:

ColorValue | { light: ColorValue, dark: ColorValue }

Along with a parameter named either light or dark supplied to my function, I will use it like:

const styles = myFunction({
  container:{
    backgroundColor: {
      light: 'red',
      dark: 'blue'
    },
    flex: 1
  },
}, 'dark');

And it will pick the appropriate key, e.g. it will return:

{
    container:{
      backgroundColor: 'blue' //as we picked the 'dark' key from original input
      flex: 1
    }
}

In plain English, I want my function's input type to accept anything that StyleSheet.create accepts, and in addition to that, also accept {light: ColorValue, dark: ColorValue} wherever it accepted ColorValue. It then picks the appropriate keys, then calls StyleSheet.create internally and returns the result.

I wrote the function and it works, but I'm struggling to make it accept objects with the light/dark keys in IntelliSense and linter (of course, without casting to any or unknown).

How do I achieve this?

1

There are 1 answers

0
jcalz On BEST ANSWER

I think you might benefit from having a few conditional, mapped types like this:

type DeepReplaceSupertype<T, S, D> = [S] extends [T] ? D : {
    [K in keyof T]: DeepReplaceSupertype<T[K], S, D>
}

type DeepReplaceSubtype<T, S, D> = [T] extends [S] ? D : {
    [K in keyof T]: DeepReplaceSubtype<T[K], S, D>
}

The idea is that DeepReplaceSupertype<T, S, D> takes a type T, a source type S, and a destination type D and it checks: if S is assignable to T, then replace it with D. Otherwise, walk down recursively through T and replace all properties and subproperties the same way. DeepReplaceSubtype<T, S, D> is similar but it checks if T is assignable to S.

Then your function's signature could look like this:

interface Shade { light: ColorValue, dark: ColorValue }

export declare function myFunction<
    T extends DeepReplaceSupertype<NamedStyles<any>, ColorValue, Shade>>(
        styles: T, shade: 'light' | 'dark'
    ): DeepReplaceSubtype<T, Shade, ColorValue>;

Meaning: accept anything of type T where you take that NameStyles and replace subproperties that accept ColorValue and change them to Shade. And then, the return value should take T and replace any subproperties that are a subtype of Shade and turn them back to ColorValue. It should work like this:

const styles = StyleSheet.myFunction({
    container: {
        backgroundColor: {
            light: 'red',
            dark: 'blue'
        },
        flex: 1
    },
}, 'dark');

/* const styles: {
    container: {
        backgroundColor: ColorValue;
        flex: number;
    };
} */

Looks like what you want, I think. You might be able to get even more clever and have the output type be specifically the type of the value picked from light or dark (so string instead of ColorValue), but that would be more complicated and probably not necessary, so I'll stop there.

Playground link to code