4

I am extending the styled-components module like

import "styled-components";

declare module "styled-components" {
  type Breakpoint = "small" | "medium" | "large" | "extra";

  export interface DefaultTheme {
    devices: { [P in keyof Breakpoint]: number };
    colors: {
      main: string;
      secondary: string;
    };
  }
}

I use it here

import { DefaultTheme } from "styled-components";

const breakpoints = {
  small: 576,
  medium: 768,
  large: 992,
  extra: 1200
};

const theme: DefaultTheme = {
  devices: Object.fromEntries<string>(
    Object.entries(breakpoints).map(([k, v]) => [k, `(min-width: ${v}px)`])
  ),
  colors: {
    main: "blue",
    secondary: "red"
  }
};

export default theme;

How can I get proper type safety here? 2 issues I'm having: theme.devices:

Type '{ [x: string]: string; [x: number]: string; }' is missing the following properties from type '{ [x: number]: number; toString: number; charAt: number; charCodeAt: number; concat: number; indexOf: number; lastIndexOf: number; localeCompare: number; match: number; replace: number; search: number; slice: number; ... 34 more ...; trimRight: number; }': charAt, charCodeAt, concat, indexOf, and 40 more.

And where I try to use it I get all string methods along with the custom breakpoints properties while I would only like to have the breakpoints properties

`@media ${props => props.theme.devices.small}` {

1 Answers1

1

Shouldn't the type of the devices property be declared not as { [P in keyof Breakpoint]: number } but as { [P in Breakpoint]: string } (or equivalently Record<Breakpoint, string>)? keyof Breakpoint is basically keyof string, the names of properties and methods on a string, like length and charAt... not what you want. And you the values you are using inside theme.devices are strings, not numbers.

I will assume this definition of DefaultTheme:

export interface DefaultTheme {
  devices: { [P in Breakpoint]: string };
  colors: {
    main: string;
    secondary: string;
  };
}

On to Object.fromEntries() and Object.entries(). The type system isn't really expressive enough to guarantee that objects only have known property keys. Object types in TypeScript are extendable/open; they are not exact. If I have an interface Obj { someProp: string } and an interface ObjX extends Obj { extraProp: number }, and you hand me an object obj of type Obj, for all I know it may also be of type ObjX and has an extraProp key. And for all I know it may be of some other type that extends Obj and has all sorts of unknown extra keys. Thus Object.keys(obj) returns string[] and not Array<keyof Obj>, and similarly Object.entries(obj) returns Array<[string, any]> (or maybe Array<[string, V]> for some value type V if obj is an indexable type). And Object.fromEntries() is just as weakly-keyed, turning any set of entries into an object of type {[k: string]: V} where V is the union of all entry value types. The compiler doesn't know how to verify that Object.fromEntries(Object.entries()...) turns an object type T into some related object type, and so it doesn't even try.

Therefore I'd suggest that you will have to use a type assertion somewhere to tell the compiler that you are verifying that the types are correct. In this case, if you find yourself mapping over values and preserving keys often, I might make a helper function with just one type assertion, and you can reuse that function elsewhere:

function mapValues<T extends object, V>(obj: T, valueMapper: (k: T[keyof T]) => V) {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => [k, valueMapper(v)])
  ) as { [K in keyof T]: V };
}

This just asserts that mapValues(obj, valueMapper) will return an object with the same keys as obj and whose values will all be that of the return type of valueMapper. You can use it like this:

const theme: DefaultTheme = {
  devices: mapValues(breakpoints, v => `(min-width: ${v}px`),
  colors: {
    main: "blue",
    secondary: "red"
  }
};

And that should hopefully compile without errors. Good luck!

Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360