C# DateTimeを独自コードに変換する方法|Variant1/2対応の実装例(.NET Framework 4.8)

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→L2→B
日1〜9→1〜9, 10→0, 11→A, 12→B …5→5
結合年 + 月 + 日(区切りなし)I + B + 5 → IB5

「日」のマッピングは、実務で一番ズレやすい部分です。仕様どおりの並びを保証するため、次のように1〜31 をすべて明示した対応表を用意しておくのが安全です。

日コード日コード日コード
1112B23M
2213C24N
3314D25O
4415E26P
5516F27Q
6617G28R
7718H29S
8819I30T
9920J31U
10021K––
11A22L––

この対応表どおりに実装できていれば、たとえば 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→C2→2
日01〜31(2桁固定推奨/可変も可)05
結合年 + 月 + 日9 + 2 + 05 → 9205

年の対応を一部だけ抜粋すると次のようになります(運用上は 2017 より前も扱う可能性があるため、「基準年」を決めておくと保守が楽です)。

西暦年コード西暦年コード西暦年コード
201712021520259
20182202262026A
20193202372027B
20204202482028C

実装方針の選び方:手軽さ重視か、運用の扱いやすさ重視か

同じ変換でも、実装の置き場所によって「呼び出し側の書きやすさ」「テストのしやすさ」「仕様変更の安全性」が変わります。結論から言うと、次の棲み分けが現実的です。

方式おすすめの場面メリット注意点
拡張メソッド(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)
V1Variant 1IB5
V2Variant 2(日=2桁固定)9205
V2NVariant 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)結果
100Aズレる
11ABズレる
15EFズレる

仕様の並びが少しでも違うなら、マッピング表で明示するほうが「後から見た人が理解できる」「要件変更に耐える」という面で圧倒的に強いです。

仕様を壊さないためのテスト例(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 を選ぶと、実装と保守のバランスが取りやすくなります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次