---
type: learn
language: javascript
slug: string/unicode
title: "文字を正しく数える — 絵文字と結合文字"
title_tag: "JavaScript で文字を正しく数える — 絵文字と length【第4回】"
summary: >
  length が見た目の文字数と合わない理由を、サロゲートペアと結合文字から説明します。
  スプレッド構文と Intl.Segmenter による数え方の違い、正規化と並べ替えまで実行して確かめます。
description: >
  length は符号単位を数えるので、絵文字が入ると見た目の文字数と合いません。スプレッド構文で足りる場面、Intl.Segmenter が要る場面、正規化の必要性まで扱います。
status: published
difficulty: 3
minutes: 11

versions:
  verified: "Node 22.22.3"
  since: "ES2015"
  deprecated: null
  removed: null

sources:
  - title: "String.prototype.length — ECMAScript® 2026 Language Specification"
    url: "https://tc39.es/ecma262/#sec-properties-of-the-string-prototype-object"
  - title: "Intl.Segmenter — ECMAScript® 2026 Internationalization API Specification"
    url: "https://tc39.es/ecma402/#segmenter-objects"
  - title: "Unicode Standard Annex #29: Unicode Text Segmentation"
    url: "https://unicode.org/reports/tr29/"

terms: [符号単位, 符号位置, サロゲートペア, 書記素, 正規化]

links:
  prev: javascript/learn/string/search
  next: null
  related:
    - javascript/reference/string/split
    - javascript/how-to/string/truncate

content_updated_at: 2026-09-08
published_at: 2026-09-08
---

[第1回](/ja/javascript/learn/string/basics/)で `length` を使いました。**あれは、いつでも正しいわけではありません。**

```js run
console.log('あいう'.length);
console.log('あ🙂い'.length);
```
```output
3
4
```

見た目はどちらも[num:3]文字です。**なのに、絵文字が入ると[num:4]になりました。**

## `length` が数えているもの

JavaScript の文字列は[type:UTF-16]という形式で持たれています。
`length` が返すのは、その[key:符号単位（code unit）の個数]です。

多くの文字は符号単位1つで表せます。**ですが、表しきれない文字があります。**

```js run
console.log('あ'.length);
console.log('🙂'.length);
```
```output
1
2
```

絵文字は符号単位[num:2]つの組で表されます。これを[type:サロゲートペア]と呼びます。

**つまり `length` は嘘をついていません。**
「文字の数」を数えているつもりが無いだけで、[em:符号単位の数]という約束を守っています。
数えたいものが違う、というだけの話です。

### 1つの文字としての番号を見る

`codePointAt()` は、[type:符号位置（code point）]を返します。こちらが「文字そのものの番号」です。

```js run
console.log('あ'.codePointAt(0));
console.log('🙂'.codePointAt(0));
console.log(String.fromCodePoint(128578));
```
```output
12354
128578
🙂
```

[num:65535]（`0xFFFF`）を超える番号の文字が、サロゲートペアになります。

## 符号位置ごとに分ける

スプレッド構文 `[...]` と `Array.from()` は、[key:符号位置ごと]に分けてくれます。

```js run
const text = 'あ🙂い';

console.log(text.length);
console.log([...text].length);
console.log([...text]);
```
```output
4
3
[ 'あ', '🙂', 'い' ]
```

**見た目と合いました。** 多くの場面では、これで十分です。

## それでも合わないものがある

家族の絵文字を数えてみます。

```js run
const family = '👨‍👩‍👧';

console.log(family.length);
console.log([...family].length);
```
```output
8
5
```

見た目は[num:1]文字なのに、符号位置でも[num:5]あります。
これは**3人の絵文字を[key:見えないつなぎ記号]で連結したもの**だからです。

肌の色を指定した絵文字でも同じことが起きます。

```js run
console.log('👍🏽'.length);
console.log([...'👍🏽'].length);
```
```output
4
2
```

「親指」と「肌の色」の[num:2]つが組み合わさっています。

## 見た目の1文字＝書記素

人が「1文字」と感じる単位を[type:書記素（grapheme cluster）]と呼びます。
これを数えるのが `Intl.Segmenter` です。

```js run
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });

function countCharacters(text) {
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters('あ🙂い'));
console.log(countCharacters('👨‍👩‍👧'));
console.log(countCharacters('👍🏽'));
```
```output
3
1
1
```

**ようやく見た目と一致しました。**

### 3つの数え方

| 数え方 | 書き方 | `'👨‍👩‍👧'` |
|---|---|---|
| 符号単位 | `text.length` | [num:8] |
| 符号位置 | `[...text].length` | [num:5] |
| 書記素 | `Intl.Segmenter` | [num:1] |

**どれが正しいということはありません。** 目的で選びます。

- 保存できる長さの上限を見る → `length`（データの大きさに近い）
- 1文字ずつ処理する → `[...text]`
- **入力欄の「あと何文字」を出す → `Intl.Segmenter`**

[dim:`Intl.Segmenter` は 2024 年に主要なブラウザで揃いました。古い環境も相手にするなら対応状況を確認してください。]

### 単語で区切ることもできる

`granularity` を変えると、[fn:単語の単位]で分けられます。

```js run
const segmenter = new Intl.Segmenter('ja', { granularity: 'word' });

console.log([...segmenter.segment('今日は良い天気です')].map((s) => s.segment));
```
```output
[ '今日', 'は', '良い', '天気', 'です' ]
```

**日本語には単語の区切りに空白がありません。** それでも分けられます。

## 見た目が同じでも、等しくない

ここからは別の問題です。`が` は[num:2]通りの書き方があります。

```js run
const composed = 'が';
const decomposed = 'か' + '゙';

console.log(decomposed);
console.log(composed === decomposed);
console.log(composed.length, decomposed.length);
```
```output
が
false
1 2
```

**画面には同じ `が` が出ているのに、等しくありません。**
片方は「が」1文字、もう片方は「か」＋「濁点」の[num:2]文字です。

macOS のファイル名やコピーしてきた文字列で、実際に起きます。

### `normalize()` で揃える

比べる前に、どちらかの形へ[type:正規化]します。

```js run
const composed = 'が';
const decomposed = 'か' + '゙';

console.log(composed.normalize('NFC') === decomposed.normalize('NFC'));
```
```output
true
```

**利用者が入力した文字列を比べるときは、正規化してから比べてください。**

`NFKC` を使うと、半角カタカナや全角英数も揃います。

```js run
console.log('ｱ'.normalize('NFKC'));
console.log('Ａ１'.normalize('NFKC'));
```
```output
ア
A1
```

検索の照合に向きますが、[bad:元の表記は失われます]。保存する値には使わないでください。

## 並べ替えも辞書順ではない

`sort()` は[key:符号位置の順]に並べます。人が期待する順とは違います。

```js run
const words = ['banana', 'Apple', 'cherry', 'Date'];

console.log([...words].sort());
```
```output
[ 'Apple', 'Date', 'banana', 'cherry' ]
```

**大文字が先にまとまってしまいました。** 符号位置では大文字のほうが小さいからです。

`localeCompare()` を使うと、辞書の順に近づきます。

```js run
const words = ['banana', 'Apple', 'cherry', 'Date'];

console.log([...words].sort((a, b) => a.localeCompare(b, 'ja')));
```
```output
[ 'Apple', 'banana', 'cherry', 'Date' ]
```

[dim:ひらがな同士のように、既定の並べ替えでも同じ結果になる場合はあります。違いが出るかどうかは中身によります。]

## よくある間違い

### `split('')` で1文字ずつに分ける

```js bad
const text = 'あ🙂い';
const reversed = text.split('').reverse().join('');

console.log(reversed.length);
console.log(reversed === 'い🙂あ');
```
```output
4
false
```

`split('')` は[key:符号単位で分けます]。
サロゲートペアが[bad:2つに割れてから逆順になる]ので、絵文字が壊れます。

スプレッド構文なら符号位置ごとに分かれるので、壊れません。

```js run
const text = 'あ🙂い';
const reversed = [...text].reverse().join('');

console.log(reversed);
console.log(reversed === 'い🙂あ');
```
```output
い🙂あ
true
```

[bad:ただし、これでも結合文字は壊れます]。家族の絵文字を逆順にすれば分解されます。
**確実に扱うなら `Intl.Segmenter` で分けてください。**

### 途中で切ると壊れる

```js bad
const text = 'あ🙂い';

console.log(text.slice(0, 2).length);
console.log(text.slice(0, 2) === 'あ🙂');
```
```output
2
false
```

`slice()` も符号単位で切ります。**絵文字の途中で切れました。**
文字数で切り詰めるなら、先に `[...text]` か `Intl.Segmenter` で分けてから取ってください。

## まとめ

- `length` は[type:符号単位]の数。**見た目の文字数ではない**
- 絵文字は符号単位[num:2]つ（[type:サロゲートペア]）
- `[...text]` は符号位置ごと。**多くの場面ではこれで足りる**
- 家族や肌の色の絵文字は、符号位置でも[num:1]にならない
- 見た目の1文字＝[type:書記素]。数えるなら `Intl.Segmenter`
- 同じに見えて等しくない文字がある。比べる前に `normalize()`
- `sort()` は符号位置の順。辞書順に近づけるなら `localeCompare()`

## この連載はここまで

文字列を作り、切り出し、探し、正しく数えるところまで来ました。
ここから先は、必要になったときに引ける形でまとめてあります。

- [String.prototype.split()](/ja/javascript/reference/string/split/)
  … 空文字で分けたときの挙動、連続した区切りの扱い
- [String.prototype.replace() と replaceAll()](/ja/javascript/reference/string/replace/)
  … `$` が特別扱いされる落とし穴、関数で置き換える方法
- [文字列を数値に変換する](/ja/javascript/how-to/string/to-number/)
  … `Number()` と `parseInt()` の違い、失敗を見分ける方法
- [配列の基本](/ja/javascript/learn/array/basics/)
  … `[...text]` で得られる配列を、そのまま扱えるようになります
