C# の DateTime を、年・月・日を数字や英字に置き換える「独自コード」へ変換したい――そんな要件は ToString の標準書式だけでは完結しません。本記事では Variant 1/2 のルールを整理し、.NET Framework 4.8 でも動く実装(拡張メソッド/ICustomFormatter)を具体例付きで解説します。
標準の DateTime.ToString でできること・できないこと
日付を文字列にするだけなら、DateTime.ToString("yyyyMMdd") のような標準の書式指定で十分です。しかし、今回のように「数字→英字」「英字→数字」といった任意の置き換えルールが絡むと、標準書式だけでは表現できません。
| やりたいこと | 標準書式だけで可能? | 理由と現実的な対応 |
|---|---|---|
| 2025/02/05 → 20250205(8桁の数値) | 可能 | dt.ToString("yyyyMMdd") で完結します。 |
| 年・月を英字(A=2017…)に置き換えたい | 不可 | 標準書式は「年=数字」「月=数字/名前」などの固定表現。任意マッピングは自作が必要です。 |
| 日を 1〜9, 0(=10), A(=11)… のように変換したい | 不可 | 16進表記(X)で代用するとズレる可能性があるため、マッピング表で明示します。 |
| Variant を増やしても呼び出し側の書き換えを最小化したい | 工夫が必要 | 運用では IFormatProvider + ICustomFormatter を使うと扱いやすくなります。 |
今回の独自ルールを「仕様」として整理する
実装前に、Variant 1 / Variant 2 のルールをテーブル化して固定します。ここが曖昧だと、運用後に「同じ日なのに環境によって違う文字列が出る」「年の範囲が増えたときに破綻する」などの事故が起きやすくなります。
Variant 1:年・月=英字、日=1〜9/0/A…(例:IB5, IBA)
Variant 1 は、年と月を A, B, C…で表し、日だけは特殊な並びで表します。例として「A=2017」の場合、2025 年は I(2017→A から 9 番目)になります。月は 1→A, 2→B… とします。
| 項目 | 変換ルール | 例(2025-02-05) |
|---|---|---|
| 年 | 2017→A, 2018→B … | 2025→I |
| 月 | 1→A, 2→B … 12→L | 2→B |
| 日 | 1〜9→1〜9, 10→0, 11→A, 12→B … | 5→5 |
| 結合 | 年 + 月 + 日(区切りなし) | I + B + 5 → IB5 |
「日」のマッピングは、実務で一番ズレやすい部分です。仕様どおりの並びを保証するため、次のように1〜31 をすべて明示した対応表を用意しておくのが安全です。
| 日 | コード | 日 | コード | 日 | コード |
|---|---|---|---|---|---|
| 1 | 1 | 12 | B | 23 | M |
| 2 | 2 | 13 | C | 24 | N |
| 3 | 3 | 14 | D | 25 | O |
| 4 | 4 | 15 | E | 26 | P |
| 5 | 5 | 16 | F | 27 | Q |
| 6 | 6 | 17 | G | 28 | R |
| 7 | 7 | 18 | H | 29 | S |
| 8 | 8 | 19 | I | 30 | T |
| 9 | 9 | 20 | J | 31 | U |
| 10 | 0 | 21 | K | – | – |
| 11 | A | 22 | L | – | – |
この対応表どおりに実装できていれば、たとえば 2025-02-11 は IBA(I=2025, B=2月, A=11日)となります。
Variant 2:年=1..9,A.. / 月=1..9,A..C / 日=01..31(例:9205, 9211)
Variant 2 は、年を 2017→1 … 2025→9, 2026→A… のように置き換え、月も 10→A, 11→B, 12→C とします。日付は「01〜31」をそのまま結合します(2桁固定にすると桁数が安定し、検索やソート、コード体系の読み取りが楽になります)。
| 項目 | 変換ルール | 例(2025-02-05) |
|---|---|---|
| 年 | 2017→1, 2018→2 … 2025→9, 2026→A… | 2025→9 |
| 月 | 1〜9→1〜9, 10→A, 11→B, 12→C | 2→2 |
| 日 | 01〜31(2桁固定推奨/可変も可) | 05 |
| 結合 | 年 + 月 + 日 | 9 + 2 + 05 → 9205 |
年の対応を一部だけ抜粋すると次のようになります(運用上は 2017 より前も扱う可能性があるため、「基準年」を決めておくと保守が楽です)。
| 西暦 | 年コード | 西暦 | 年コード | 西暦 | 年コード |
|---|---|---|---|---|---|
| 2017 | 1 | 2021 | 5 | 2025 | 9 |
| 2018 | 2 | 2022 | 6 | 2026 | A |
| 2019 | 3 | 2023 | 7 | 2027 | B |
| 2020 | 4 | 2024 | 8 | 2028 | C |
実装方針の選び方:手軽さ重視か、運用の扱いやすさ重視か
同じ変換でも、実装の置き場所によって「呼び出し側の書きやすさ」「テストのしやすさ」「仕様変更の安全性」が変わります。結論から言うと、次の棲み分けが現実的です。
| 方式 | おすすめの場面 | メリット | 注意点 |
|---|---|---|---|
拡張メソッド(dt.ToVariant1Code()) | まず動くものが欲しい/変換箇所が限定的 | 実装が短い。呼び出しが読みやすい。 | 文字列の組み立てが散らばると将来の変更が怖い(共通関数化は必須)。 |
| ユーティリティクラス(静的メソッド) | ライブラリ化して全プロジェクトで統一したい | 仕様が一箇所に集約できる。テストも書きやすい。 | フォーマット指定子の柔軟性は自前で設計する必要がある。 |
カスタムフォーマッター(IFormatProvider + ICustomFormatter) | ログ、帳票、UI など「書式指定」を統一したい | string.Format の枠組みに載せられる。Variant の切替が簡単。 | 実装が少し長い。フォールバック処理を入れないと既存書式が壊れる。 |
最短で動かす:拡張メソッドで Variant 1 / Variant 2 を実装する
まずは「要件どおりに変換できること」を優先し、マッピング表(文字列/配列)でインデックス参照する実装を紹介します。後から年の範囲が増えたり、日コードの並びが変わったりしても、表を差し替えるだけで追従できます。
using System;
using System.Globalization;
public static class DateTimeVariantCodes
{
// Variant 1: 2017->A, 2018->B ...
private const int Variant1BaseYear = 2017;
private const string Letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// Variant 1: day mapping (index = day). 10->0, 11->A ...
// index: 0 is dummy
private const string Variant1DayTable = " 1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// Variant 2: 2017->1 ... 2025->9, 2026->A ...
// index: 0 is dummy (2016->0)
private const int Variant2BaseYear = 2016;
private const string Base36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private const string Variant2MonthTable = "0123456789ABC"; // 1..9, A(10), B(11), C(12)
public static string ToVariant1Code(this DateTime dt)
{
char y = MapVariant1Year(dt.Year);
char m = MapVariant1Month(dt.Month);
char d = MapVariant1Day(dt.Day);
return string.Concat(y, m, d);
}
public static string ToVariant2Code(this DateTime dt, bool dayFixed2Digits = true)
{
char y = MapVariant2Year(dt.Year);
char m = MapVariant2Month(dt.Month);
string d = dayFixed2Digits
? dt.Day.ToString("00", CultureInfo.InvariantCulture)
: dt.Day.ToString(CultureInfo.InvariantCulture);
return string.Concat(y, m, d);
}
private static char MapVariant1Year(int year)
{
int index = year - Variant1BaseYear;
if (index < 0 || index >= Letters.Length)
throw new ArgumentOutOfRangeException(nameof(year), "Variant 1 の年が対応範囲外です。");
return Letters[index];
}
private static char MapVariant1Month(int month)
{
int index = month - 1;
if (index < 0 || index >= 12)
throw new ArgumentOutOfRangeException(nameof(month));
return Letters[index]; // 1->A ... 12->L
}
private static char MapVariant1Day(int day)
{
if (day < 1 || day > 31)
throw new ArgumentOutOfRangeException(nameof(day));
return Variant1DayTable[day];
}
private static char MapVariant2Year(int year)
{
int index = year - Variant2BaseYear; // 2017 => 1
if (index < 0 || index >= Base36.Length)
throw new ArgumentOutOfRangeException(nameof(year), "Variant 2 の年が対応範囲外です。");
return Base36[index];
}
private static char MapVariant2Month(int month)
{
if (month < 1 || month > 12)
throw new ArgumentOutOfRangeException(nameof(month));
return Variant2MonthTable[month];
}
}
呼び出し側は次のようにシンプルになります。
var dt1 = new DateTime(2025, 2, 5);
var dt2 = new DateTime(2025, 2, 11);
string v1a = dt1.ToVariant1Code(); // IB5
string v1b = dt2.ToVariant1Code(); // IBA
string v2a = dt1.ToVariant2Code(); // 9205
string v2b = dt2.ToVariant2Code(); // 9211
この段階で重要なのは、年の範囲チェックと日コードの厳密性です。運用で年が 2017 より前に広がる可能性があるなら、Variant 1 の年コードを「英字だけ」にこだわるのか、あるいは英字+数字などへ拡張するのかを、設計として決めておくと後悔しにくいです。
実運用で扱いやすくする:ICustomFormatter で Variant を切り替える
拡張メソッドは最短で導入できますが、ログ・帳票・UI など「日付の表示点が多い」システムでは、書式指定子で Variant を選べると運用が一気に楽になります。そこで役立つのが IFormatProvider と ICustomFormatter です。
ここでは、次のようなフォーマット指定子を想定します。
| 指定子 | 意味 | 出力例(2025-02-05) |
|---|---|---|
V1 | Variant 1 | IB5 |
V2 | Variant 2(日=2桁固定) | 9205 |
V2N | Variant 2(日=可変) | 925 |
yyyy/MM/dd など | 通常の DateTime 書式 | 2025/02/05 |
ポイントは、Variant 指定以外は通常の ToString にフォールバックすることです。これを入れておくと、既存の書式(yyyyMMdd や HH:mm など)を壊さずに導入できます。
using System;
using System.Globalization;
public sealed class VariantDateTimeFormatter : IFormatProvider, ICustomFormatter
{
public object GetFormat(Type formatType)
{
// ICustomFormatter が欲しいと言われたときだけ自分を返す
return formatType == typeof(ICustomFormatter) ? this : null;
}
public string Format(string format, object arg, IFormatProvider formatProvider)
{
if (arg == null) return string.Empty;
// DateTime / DateTimeOffset のみ対象にする(必要なら拡張)
DateTime dt;
if (arg is DateTime)
{
dt = (DateTime)arg;
}
else if (arg is DateTimeOffset)
{
dt = ((DateTimeOffset)arg).DateTime;
}
else
{
return HandleOtherFormats(format, arg);
}
if (string.IsNullOrEmpty(format))
{
// 何も指定がない場合は既定の表現へ
return dt.ToString(CultureInfo.CurrentCulture);
}
string f = format.Trim().ToUpperInvariant();
// Variant 指定
if (f == "V1")
return DateTimeVariantCodes.ToVariant1Code(dt);
if (f == "V2")
return DateTimeVariantCodes.ToVariant2Code(dt, dayFixed2Digits: true);
if (f == "V2N")
return DateTimeVariantCodes.ToVariant2Code(dt, dayFixed2Digits: false);
// それ以外は通常の DateTime 書式として扱う
return dt.ToString(format, CultureInfo.CurrentCulture);
}
private static string HandleOtherFormats(string format, object arg)
{
// DateTime 以外が来たときは、.NET 標準の振る舞いに寄せる
IFormattable formattable = arg as IFormattable;
if (formattable != null)
return formattable.ToString(format, CultureInfo.CurrentCulture);
return arg.ToString();
}
}
呼び出し側は string.Format のオーバーロードを使います。運用コードが増えたときに差が出るポイントです。
var fmt = new VariantDateTimeFormatter();
var dt = new DateTime(2025, 2, 5);
string a = string.Format(fmt, "出荷日コード={0:V1}", dt); // 出荷日コード=IB5
string b = string.Format(fmt, "出荷日コード={0:V2}", dt); // 出荷日コード=9205
// Variant 指定以外は通常の DateTime 書式がそのまま使える
string c = string.Format(fmt, "表示用={0:yyyy/MM/dd}", dt); // 表示用=2025/02/05
.NET Framework 4.8 でハマりやすいポイント
ネット上のサンプルでは、switch 式やパターンマッチングなど「新しめの C# 構文」が使われていることがあります。プロジェクトが .NET Framework 4.8 でも、コンパイラ設定(言語バージョン)や開発環境によってはビルドできないケースがあるため、if / switch 文ベースの素直な実装にしておくのが無難です。
- GetFormat の実装は必須:これがないと
string.Formatがカスタムフォーマッターを見つけられません。 - フォールバックを入れる:Variant 以外の format も受け付けると、既存コードに入れやすくなります。
- CultureInfo を意識する:コード体系は文化依存にしたくないことが多いので、数値部分は
InvariantCultureを使うのが安全です。
16進数表記(”X”)で代用しないほうが良い理由
「日を 1〜9, A〜F… のようにしたいなら、dt.Day.ToString("X") で行けるのでは?」と考えたくなります。しかし今回の Variant 1 の日コードは 10 が “0” で、11 が A という独自並びです。16進表記は 10 が A になってしまうため、要件と一致しません。
| 日 | 要件(Variant 1) | 16進表記(X) | 結果 |
|---|---|---|---|
| 10 | 0 | A | ズレる |
| 11 | A | B | ズレる |
| 15 | E | F | ズレる |
仕様の並びが少しでも違うなら、マッピング表で明示するほうが「後から見た人が理解できる」「要件変更に耐える」という面で圧倒的に強いです。
仕様を壊さないためのテスト例(MSTest)
独自コード化は、一見すると単純な変換ですが、年の境界や 10/11/31 といった境界値でズレやすいです。運用に入る前に、最低限のテストを作って「仕様が守られている」ことを固定しておくと安心です。
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class DateTimeVariantCodesTests
{
[TestMethod]
public void Variant1_Example()
{
var dt = new DateTime(2025, 2, 5);
Assert.AreEqual("IB5", dt.ToVariant1Code());
}
[TestMethod]
public void Variant1_Day10_IsZero()
{
var dt = new DateTime(2025, 2, 10);
Assert.AreEqual("IB0", dt.ToVariant1Code());
}
[TestMethod]
public void Variant1_Day11_IsA()
{
var dt = new DateTime(2025, 2, 11);
Assert.AreEqual("IBA", dt.ToVariant1Code());
}
[TestMethod]
public void Variant2_Example()
{
var dt = new DateTime(2025, 2, 5);
Assert.AreEqual("9205", dt.ToVariant2Code(dayFixed2Digits: true));
Assert.AreEqual("925", dt.ToVariant2Code(dayFixed2Digits: false));
}
[TestMethod]
public void Variant2_Year2026_IsA()
{
var dt = new DateTime(2026, 12, 31);
Assert.AreEqual("AC31", dt.ToVariant2Code());
}
}
テストがあると、将来「基準年を変えたい」「Variant 2 の日を常に 2 桁に統一したい」といった変更が入っても、意図せずコード体系が変わってしまう事故を防げます。
実運用での落とし穴と対策
最後に、現場でありがちな落とし穴をまとめます。独自コードは「見た目が短くて便利」な反面、運用設計が弱いとトラブルになりやすい領域です。
| 落とし穴 | 起きがちな問題 | 対策 |
|---|---|---|
| 年の範囲が想定外に伸びる | ある年から変換できず例外/別体系が乱立 | 基準年と対応範囲を仕様書・コードに明記し、例外メッセージも具体化する |
| 日コードの並びが曖昧 | 実装者によって 10 の扱いが変わる | 1〜31 の対応表を明示し、テストで境界値(9/10/11/31)を固定する |
| TimeZone/Kind の混在 | 同じ瞬間なのに日付がずれて別コードになる | 入力がローカル日付なのか UTC なのかを統一し、必要なら DateTimeOffset で受ける |
| フォーマット指定の拡張が場当たり | Variant が増えるたびに呼び出し側が破壊的変更 | ICustomFormatter で書式指定子を設計し、Variant を内部で切替える |
拡張しやすい設計のコツ:マッピング表と「基準年」を分離する
Variant の増加や年範囲の拡張に備えるなら、次の 2 点を押さえると保守が安定します。
- マッピング表をコード上の定数として一箇所に集約(散らばると仕様が割れて破綻します)。
- 基準年(BaseYear)を明示し、変換式を「年 – BaseYear」で統一する(後から読みやすく、変更もしやすい)。
さらに運用を堅くするなら、Variant の仕様を「バージョン」として扱うのも有効です。たとえば、過去データは Variant 1、新規データは Variant 2、といった移行がある場合でも、フォーマット指定子を V1/V2 にしておけば、呼び出し側の文字列処理は変えずに済みます。
まとめ:独自日付コードは「標準書式+自作マッピング」が最短ルート
Variant 1 / Variant 2 のような独自ルールは、標準の DateTime.ToString だけで完結させるのは難しいため、マッピング表を使った自作変換が前提になります。そのうえで、導入の手軽さなら拡張メソッド、運用の扱いやすさなら ICustomFormatter を選ぶと、実装と保守のバランスが取りやすくなります。

コメント