C# class for translatable string
Localization

In my experience, translations are often provided per language, so I would normally store them per language rather than per piece of text.

I also prefer to have a single 'localization' object, rather than many 'translatable strings'. It gives me a clear entry-point for loading translations into memory and it provides a simple interface to other code: a single object with a GetText(string languageCode, string key) method.

Most applications only use a single language most of the time, so you may not even need to load all translations into memory. A central localization object makes it easier to perform such optimizations.

Some localization systems use the default (often English) text as a lookup key. But lately I've been using 'hierarchical' keys instead, such as MainWindow.Menu.FileButton or Settings.DataSources.DataSourceName. It gives translators slightly more context, which makes it easier for them to provide more accurate translations. And sometimes, where the default language uses the same word, another language uses different words depending on context. The hierarchical aspect of these keys allows me to organize the translations somewhat, so it's easier to prevent duplicate entries and all that.

Other notes


I don't think implicit conversion operators are a good idea here. They only really make sense when you're converting between similar types, and only when there's no risk of data loss. That's not the case here:


Converting a string to a TranslatableString gives you a translatable string that doesn't contain any translations. I don't see why that is useful, since the whole point of this class is to give access to translations.
Converting a TranslatableString to a string gives you the default text, but you lose all the translations. Instead, I would turn that Text field into a property with a public getter.

TranslatableString only needs one constructor if you give the value parameter a default value: (string value = null). Also, C# 6.0 introduced auto-property initializers, so you can further reduce code repetition in constructors: public List&lt;Translation&gt; Translations { get; set; } = new List&lt;Translation&gt;();
Making that Translations property publicly settable is probably not a good idea. I assume you're doing it for the free serialization, but it also allows any other code to modify it.
It's good to see some documentation! Perhaps you could add a small example with some hierarchical language codes for those that haven't memorized their ISO standards?


The ToTranslatedString method


It's doing double work. if (Any()) item = First() has to find a matching item twice. item = FirstOrDefault(); if (item != null) only has to find the item once.
A dictionary that maps language codes to translations would be a more appropriate data structure here.
Rather than repeatedly calling ToLower(), obtain a lower-case code beforehand, and make sure that your translations only use lower-case codes internally.
The main lookup code is duplicated. Instead of an if/else construction and a loop, you only need a loop. You just need to generalize the language code after you've tried to find a match.
You'll probably want to ignore a translation immediately if it's empty, rather than checking that at the end. Currently your code won't try to find a more general translation in such cases.
GetTranslation(string languageCode) is probably a better name - To implies some kind of conversion.


An improved version could look somewhat like this:

private Dictionary&lt;string, Translation&gt; _translations = new Dictionary&lt;string, Translation&gt;();

public string GetTranslation(string languageCode)
{
    languageCode = languageCode.ToLower();
    while (true)
    {
        if (_translations.TryGetValue(languageCode, out Translation translation) &amp;&amp; !string.IsNullOrEmpty(translation.Text))
            return translation.Text;

        // Try again with a more general language code, if possible:
        var dashIndex = languageCode.LastIndexOf('-');
        if (dashIndex == -1)
            break;

        languageCode = languageCode.Substring(0, dashIndex);
    }
    return Text;
}