Material UI の PaletteColor の型をいい感じに拡張させる


半年前から React を触るようになり、Material UIも触るようになりました。
とても便利ですね!

UIフレームワークはこれまで Vue で Vuetify をつかってきましたが、断然 Material UI のほうが拡張性があり、使いやすくて結構好きになりました。

ことの発端

デザイナーからの色指定で primary カラーのバリエーションに light main dark 以外の色を追加したいというユースケースが発生しました。仮に disabled とします。

具体的なコードを書くと、こんな感じにしたい。

<Box bgcolor="primary.disabled">not available</Box>

JavaScript なら createTheme のところで下記のようにかけば良いのですが、 TypeScript の場合は ThemeOptions と型が合わずにエラーとなります。

theme.ts
export const theme = createTheme({
  // 略
  palette: {
    primary: {
      light: '#ffe8d6',
      main: '#a5a58d',
      dark: '#6b705c',
      disabled: '#b7b7a4'
      // ↑ JS なら通るし Material UI の 色指定でアクセスできる
      // TS なら createTheme の時点でエラー。
      // 乱暴に @ts-ignore すればとりあえず大丈夫だが
      // theme を import したときにメンバーに出てこないのはイケてない
    }
  }
  // 略
});

上記のコメントでかいたとおり、 Material UI の theme 関係の恩恵が受けられませんが、メンバーを勝手に生やしてアクセスすること自体はできているにも関わらず、TSのコンパイルエラーになります。

なので、自前で型を変えちゃいます。

declare module を使って上書き

Vue の 2系でよくやるやつですね(遠い目)。

いきなりですが、こんな感じの d.ts を書きました。

types/createPalette.d.ts
import { Theme } from '@material-ui/core';
import { PaletteColor } from '@material-ui/core/styles/createPalette';

declare module '@material-ui/core/styles/createPalette' {
  interface CustomTheme extends Theme {
    palette: CustomPalette;
  }

  interface CustomPalette extends Palette {
    primary: CustomPaletteColor;
  }

  interface CustomPaletteColor extends PaletteColor {
    disabled?: string;
  }

  interface SimplePaletteColorOptions {
    disabled?: string;
  }
}

SimplePaletteColorOptions

ここで background を指定することで、 createTheme の palette.primary 内に disabled が使えるようになりました。

CustomPaletteColor

一方、 SimplePaletteColorOptions だけを拡張しても、 theme を import してもメンバーに disabled が生えません。なので、 styled components で色を引っ張りたい場合に問題が出ます

import React from 'react';
import { theme } from './theme.ts';

const hoge = (): JSX.Element => (
  <p style={{
    textDecoration: `underline overline ${theme.palette.primary.disabled}`
    //                                                          ~~~~~~~~ そんなものはない
  }}>hoge</p>
);

export default hoge;

なので、悩んだのですが、 既存の PaletteColor を拡張して使うことにします。

既に存在するメンバーを拡張させるのは d.ts を使ってもできないので、新しい interface を定義します。今回作成した CustomPaletteColor がそれです。

CustomPalette

上記と同じ理由です。今回は primary の PaletteColor だけを拡張させたいので、 primary だけ拡張した CustomPaletteColor を使いましたが、他の色 error とかも拡張させたい場合は、そちらも追記してください。

CustomTheme

これも上記と同じ & 本丸です。これを新しい Theme の型として使いたいのですが、悩んだ結果、 theme に対して アサーションで読み替えてあげることにしました。

export const theme = createTheme({ /* 設定値 */ }) as CustomTheme;

実行結果

import React from 'react';
import { theme } from './theme.ts';

const hoge = (): JSX.Element => (
  <p style={{
    textDecoration: `underline overline ${theme.palette.primary.disabled}`
    //                                                            ↑メンバーが生えた
  }}>hoge</p>
);

export default hoge;

これでいけそうです

もちろん、Material UIのコンポーネントでも指定できます。(こっちは文字列渡しなので型の恩恵は受けられませんが…)

<Box bgcolor="primary.disabled">not available</Box>

ところで

実は当初、型を眺めていたところ、色の指定で利用されている型 PaletteColorOptions では ColorPartial が union で指定されていて、普通に使えそうなんです。

// @material-ui/core/index.d.ts から抜粋
export interface Color {
  50: string;
  100: string;
  200: string;
  300: string;
  400: string;
  500: string;
  600: string;
  700: string;
  800: string;
  900: string;
  A100: string;
  A200: string;
  A400: string;
  A700: string;
}

// @material-ui/core/styles/createPalette.d.ts から抜粋
export type ColorPartial = Partial<Color>;

export type PaletteColorOptions = SimplePaletteColorOptions | ColorPartial;

export interface SimplePaletteColorOptions {
  light?: string;
  main: string;
  dark?: string;
  contrastText?: string;
}

ところが、実際に生える theme インスタンスの palette の中身の型は PaletteColor になっています。

// @material-ui/core/styles/createPalette.d.ts から抜粋
export interface PaletteColor {
  light: string;
  main: string;
  dark: string;
  contrastText: string;
}

同じ問題に気づいた方がいらっしゃたようで、こちらの Issue でディスカッションとなっていました。
https://github.com/mui-org/material-ui/issues/20277

で、このIssueを読んで、今回の対処法に行き着いた次第です。

まとめ

v5 ではもうちょっとかんたんに拡張できそうですね。

それでは。