Type-safe localization with LocalizedString
Many Bento components accept strings that get presented to the user (either visually or via aria attributes).
If you take a look at the source code, you'll notice these strings are typed as LocalizedString
.
LocalizedString
is a special type that defaults to string
, but it can be customized to make it more useful.
Why would you want to customize it? One good reason is to avoid to accidentally render a non localized string. Let's see an example:
function MyComponent() {
return (
<Button label="woops, not localized" onPress={() => {}} kind="solid" hierarchy="primary" />
);
}
In the example above, we forgot to localize the Button's label.
By default Bento won't complain about it, since LocalizedString
is an alias for string
.
Let's fix this!
import "@buildo/bento-design-system";
declare module "@buildo/bento-design-system" {
interface TypeOverrides {
LocalizedString: StrictLocalizedString;
}
}
Now LocalizedString
isn't just any string: it's a string which must be created "deliberately" by the developer. How can we create these strings? The simple answer is: via a cast! Aren't casts bad? Yes, they are when used indiscriminately, however the idea here is that we cast in a single place, where we do it safely, specifically in our localization function.
For example, here's a dummy localization function that simply casts the given localization key:
import { LocalizedString } from "@buildo/bento-design-system";
function formatMessage(key: string): LocalizedString {
return key as unknown as LocalizedString;
}
Let's test this:
import { formatMessage } from "../utils/formatMessage";
function MyComponent() {
return (
<>
// type error!
<Button label="woops, not localized" onPress={() => {}} kind="solid" hierarchy="primary" />
// ok!
<Button
label={formatMessage("MyComponent.buttonLabel")}
onPress={() => {}}
kind="solid"
hierarchy="primary"
/>
</>
);
}
Great! Now all Bento components will complain if we accidentally forget to localize a string that must be presented to the user ๐
Integrating with localization librariesโ
In the example above, we've seen a dummy localization function. In a real application you will likely use a library like react-intl
or react-i18next
, so you will need to wrap the localization function they provide such that it returns LocalizedString
instead of string
. Here's a couple of examples of how you could achieve it:
react-intl
+ LocalizedString
import { useIntl } from "react-intl";
import { PrimitiveType } from "intl-messageformat";
import { LocalizedString } from "@buildo/bento-react-components";
export function useFormatMessage(): (
id: string,
values?: Record<string, PrimitiveType>
) => LocalizedString {
const intl = useIntl();
return (id, values) => {
return intl.formatMessage({ id }, values) as unknown as LocalizedString;
};
}
react-i18next
+ LocalizedString
import enMessages from "./locales/en.json";
type Primitive = string | boolean | number | null | undefined;
type MapLeafNodes<Obj, LeafType> = {
[Prop in keyof Obj]: Obj[Prop] extends Primitive
? LeafType
: Obj[Prop] extends Record<string | number, any>
? MapLeafNodes<Obj[Prop], LeafType>
: never;
};
// We cast the type of messages to LocalizedString
const enResources = enMessages as MapLeafNodes<typeof enMessages, LocalizedString>;
export const resources = {
en: enResources
} as const;
i18next
.use(initReactI18next)
.init({
resources,
...
});
import { resources } from "./i18n/i18n";
declare module "i18next" {
interface CustomTypeOptions {
resources: (typeof resources)["en"];
}
}
unsafeLocalizedString
โ
For those rare cases in which you want to work around the type system, Bento also provides a unsafeLocalizedString
function which turns any string
or a number
into a LocalizedString
.
This is equivalent to casting, but the unsafe
prefix makes it clear that this is potentially dangerous and you should avoid it if possible (this is similar to dangerouselySetInnerHTML
in React: you can use it but the name clearly indicates that it's not advised).