🤖

Template Literal Types で「単位を持つ数値」の文字列型をきれいに扱いたい

2021/03/10に公開

追記: hasSuffix の実装が間違ってたので修正

ブラウザの二次元上の座標を計算するコードを書いていると、 px, rem, flex などの数値が入り乱れて、それらを文字列で管理してると扱いが難しくなります。また、ブラウザの DOM API は、コンテキスト次第で string | number みたいなノリで "250px" や 250 みたいな数値を雑に返してきます。

世の中には typescript 4.1 から使える template literal types で JSON パーサやパーサコンビネータを書く人がいるみたいですが、今回はそういう黒魔術にはできるだけ手を染めず、文字列表現に制約を掛けて、それらの計算を楽にできないかを試していました。

まずは template literal types の簡単なアイデアから。

type PixelValue = `${number}px`;
const width: PixelValue = "150px";
const height: PixelValue = "150"; // type error

これで数値型+末尾に px を持つ文字列という制約を掛けることができます。末尾が px 以外、前半部分が数値型で表現できるものでないと、型違反になります。

一般化します。

type Unit<Suffix extends string> = `${number}${Suffix}`;

今回はブラウザで扱う単位に限定するので、次のように限定しました。

type UnitSuffix = "px" | "em" | "rem" | "%" | "fr" | "";
type Unit<Suffix extends UnitSuffix> = `${number}${Suffix}`;
type FlexGrowValue = Unit<"">;
type PixelValue = Unit<"px">;
type EmValue = Unit<"em">;
type RemValue = Unit<"rem">;
type PercentValue = Unit<"%">;
type FractionValue = Unit<"fr">;

空文字があるのは、 単位を持たない flex を表現する必要があったからです。

これらをコード上判定する関数を書きます。

function hasSuffix<Suffix extends UnitSuffix>(
  value: string,
  suffix: Suffix
): value is `${number}${Suffix}` {
  return new RegExp(`[0-9]${suffix}\$`).test(value);
}

function toNum<T extends UnitSuffix>(expr: Unit<T>, _suffix: T): number | null {
  const n = Number(expr.replace(/(\d+)(px|rem|em|\%|fr|)$/, "$1"));
  if (n === NaN) return null;
  return n;
}

const n = Math.random() > 0.5 ? ("150px" as const) : ("30%" as const);
if (hasSuffix(n, "px")) {
  const x: PixelValue = n;
  const y: PercentValue = n; // => 型エラー
}

ちゃんと px 末尾ではないと、 PercentValue には代入できないのでエラーになっています。それを表現するための value is ${number}${Suffix} です。

Flex や Grid の比率から実際の値を計算する

自分がやりたかったことは、 flex や grid の実際の値をそれぞれの単位が入り乱れてる時に計算することでした。これを実装してみます。

  • px | em | rem | % の固定値のときはそのまま
  • grid の fr や flex のような値が比率のときは、全体の長さから固定値を引いた残りを、自分自身の比率で占める。

(厳密には溢れた際に伸び縮みしたり、flex gap があるとまた違う計算になるんですが、ここでは割愛します)

(今回は toNum が undefined になる可能性を!で握りつぶしてます。真面目にやるときは考慮します)

これを計算します。

type UnitSuffix = "px" | "em" | "rem" | "%" | "fr" | "";
type Unit<Suffix extends UnitSuffix> = `${number}${Suffix}`;
type FlexGrowValue = Unit<"">;
type PixelValue = Unit<"px">;
type EmValue = Unit<"em">;
type RemValue = Unit<"rem">;
type PercentValue = Unit<"%">;
type FractionValue = Unit<"fr">;

type ConstantValue = PixelValue | EmValue | RemValue;
type ProportionConstantValue = ConstantValue | PercentValue;
type ProportiolValue = FlexGrowValue | FractionValue;

type FlexProportionExpr = Array<FlexGrowValue | ProportionConstantValue>;
type GridProportionExpr = Array<FractionValue | ProportionConstantValue>;

function hasSuffix<Suffix extends UnitSuffix>(
  value: string,
  suffix: Suffix
): value is `${number}${Suffix}` {
  return new RegExp(`[0-9]${suffix}\$`).test(value);
}

function toNum<T extends UnitSuffix>(expr: Unit<T>, suffix: T): number {
  const raw = expr.replace(new RegExp(`${suffix}$`, ""), "");
  return Number(raw);
}

export function calcInnerSizes(
  proportions: Array<ProportionConstantValue | ProportiolValue>,
  size: number,
  { emPixel, remPixel }: { emPixel: number; remPixel: number }
) {
  let proportionSum = 0;
  let pixelSum = 0;

  // em や rem を計算して px or 比率の状態にする
  let pixelOrProportion: Array<PixelValue | ProportiolValue> = [];

  for (const v of proportions) {
    if (hasSuffix(v, "px")) {
      pixelOrProportion.push(v);
      const n = toNum(v, "px")!;
      pixelSum += n;
    } else if (hasSuffix(v, "rem")) {
      const n = toNum(v, "rem")! * remPixel;
      pixelOrProportion.push(`${n}px` as PixelValue);
      pixelSum += n;
    } else if (hasSuffix(v, "em")) {
      const n = toNum(v, "em")! * emPixel;
      pixelOrProportion.push(`${n}px` as PixelValue);
      pixelSum += n;
    } else if (hasSuffix(v, "%")) {
      const percent = toNum(v, "%")!;
      const n = (size * percent) / 100;
      pixelOrProportion.push(`${n}px` as PixelValue);
      pixelSum += n;
    } else {
      pixelOrProportion.push(v);
      if (hasSuffix(v, "fr")) {
        proportionSum += toNum(v, "fr")!;
      } else {
        proportionSum += toNum(v, "")!;
      }
    }
  }
  const restSize = size - pixelSum;
  return pixelOrProportion.map((v) => {
    if (hasSuffix(v, "px")) {
      return toNum(v, "px");
    } else {
      let num;
      if (hasSuffix(v, "fr")) {
        num = toNum(v, "fr")!;
      } else {
        num = toNum(v, "")!;
      }
      return (num * restSize) / proportionSum;
    }
  });
}

const sizes1 = calcInnerSizes(
  ["1", "100px", "10em", "20rem", "10%", "1"] as FlexProportionExpr,
  800,
  { emPixel: 16, remPixel: 16 }
);
console.log(sizes1);

一旦これでうまくいきました。

Discussion