Yes, the cross-platform stuff sucks on Windows. I’ve seen languages with a few different approaches, I’d like a moment to compare them here. I’m going to talk about some specific aspects of cross-platform compatibility but not attempt to compare one single aspect across many platforms.

With C++, you can use the preprocessor to give you a string type which is UTF-8 (nominally) when compiled on Unix and UTF-16 (nominally) on Windows. This is… okay-ish, workable, but I’m unaware of any good libraries that do this for you. Instead, you’re basically on your own. It’s not horrible, it’s not great, you spend some time writing interfaces to work on Windows (if you are doing anything specific). There’s no C++ “mkdir” call, for example. (Maybe there is in C++75 or whatever. They keep adding things. There was no “mkdir” in C++ for, like, 30 years.)

As a side note, there is something in C++ called “wchar_t” and “std::wstring” which is basically complete garbage and only ends up being used because somebody, in the past, wrote code that used it and now you are stuck with it. There’s a whole story here.

With C# and .NET, the APIs seem to be designed with Windows as the norm, and Unix/Unix-like systems are an afterthought with some shim for portability. You can take a look at System.Diagnostics.Process for one of the worst offenders. Anything involving pipes is just not doable with the .NET process spawning interface, as far as I can tell, because it wouldn’t match the Windows semantics. You also can’t even roll your own process spawner without great difficulty, because you can’t safely call fork() from C# (not a surprise).

With Go, the APIs are designed with Unix as the norm, as discussed in the article.

With Rust, the APIs are carefully designed to give you a subset of functionality that is present on both Windows and Unix. As a result, the available functionality is (IMO) garbage, kind of like wstring in C++. In short, with OsString / OsStr in Rust, you get a string that is hard to manipulate. In the effort to make it safe and cross-platform, it’s been made into something like an opaque box for strings, and it’s missing 90% of the typical API that you’d find on a useful string class. In an effort to make sure that you don’t pay any unnecessary cost for conversion to/from Rust strings, the subset of OsString that are valid Unicode strings can be typecast at zero cost to Rust String / &str types—but this means that the encoding of these strings doesn’t match the Windows encoding at all.

F'ing bizarre.

Honestly I do not think that there is any easy out here, at all. If you care about running on Windows, and you care about providing a good experience on both Unix-like and Windows systems, then you should embrace the fact that any cross-platform API that abstracts the differences away will be imperfect, and just ask yourself what tradeoff you think is appropriate to get a better experience on Windows and Unix-like systems.

I’ll also add that while macOS provides a fully functional, fully usable POSIX API, that doesn’t mean that this API is the best option for performing low-level tasks on macOS. In particular, you should probably be going through the NSFileManager API when possible if you really care about macOS, because this provides higher-level functionality that is tricky to implement yourself—the “correct” way to do certain operations is different depending on the semantics of the underlying filesystem.

I’m just going to finish with the note that if you want to handle the pathological edge cases when using strings in a cross-platform application, you are in for quite the wild ride. Even something really simple as “I want to list all files in a directory and send the result to a client in JSON format” is just, well, a nightmare, if you are committed to handling all the edge-cases.