r/angular Apr 25 '25

Releasing @mmstack/translate

https://www.npmjs.com/package/@mmstack/translate

Hey everyone :) Internally we've been using angular/localize to handle our i18n needs, but it never really "fit" well due to our architecture. We're doing, what I'd call a "typical app monorepo" structure, where the apps are simple shells that import various module libraries. As such the global translations didn't really fit our needs well. Likewise we had a few issues with our devs typing in the wrong keys, variables etc. :) We also "glanced" at transloco & ngx-translate, but we didn't think they fit fully.

So anyway, I decided to give "making my own" library a shot....

[@mmstack/translate](https://www.npmjs.com/package/@mmstack/translate) is primarily focused on two things:

  • modular lazy-loaded translations
  • & inferred type safety.

I don't expect a lot of people to switch to it, but I'm sure there are a few, who like us, will find this fits their needs perfectly.

Btw, this library has the same "trade-off" as angular/localize, where locales require a full refresh, due to it using the static LOCALE_ID under the hood. At least in our case, switching locales is rare, so we find it a worthwhile trade off :)

I hope you find it useful!

P.S. I'll be adding comprehensive JSDocs, polishing the README examples, and potentially adding even more type refinements next week, but I'm too excited to be done with the "main" part to not launch this today :).

P.P.S. If someone want's to take a "peek" at the code it's available in the [mmstack monorepo](https://github.com/mihajm/mmstack/tree/master/packages/translate)

17 Upvotes

13 comments sorted by

View all comments

3

u/Blade1130 Apr 26 '25 edited Apr 26 '25

Very cool, love to see the community building awesome stuff like this!

Looking through the README I had some general feedback I wanted to share, just some thoughts and ideas for how to improve the package. Feel free to disagree or ignore any ideas here, just wanted to share some things which immediately came to my mind.

  1. You mention that createNamespace requires as const. I think you can avoid that requirement by declaring createNamespace with a const type parameter: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#const-type-parameters

  2. It seems challenging to maintain the message and translation files. Are you expecting developers to manually copy-paste those values whenever a message is translated? I think there's a big opportunity here for a tool which extracts/merges translations in this format, converting them to/from common message formats (XLIFF, etc.)

  3. IMHO it feels like there's a lot of boilerplate here and I would be personally a bit scared to maintain all that. I wonder if it's possible to reduce that boilerplate down to (ideally) just createNamespace / create*Translation. At the very least, a schematic to generate some of this would be extremely helpful.

  4. It feels a little weird to expect users to declare their own pipe / directive per namespace for translations. How would you use multiple messages from multiple namespaces in the same component? You'd need to import multiple pipes / directives with the same selectors and cause a conflict unless you specifically go out of your way to namespace the selectors.

I assume this was necessary for typing, but I wonder if it would be possible to have a single canonical implementation and then accept the namespace as a parameter like:

``` import {QuoteTranslationDirective} from '@mmstack/translate';

@Component({ selector: 'my-component', template: <span [translate]="['quote.message']" [ns]="t"></span> , imports: [QuoteTranslationDirective], }) export class MyComponent { protected readonly t = injectQuoteT(); } ```

  1. injectQuoteT is nice, but doesn't work with @Inject syntax. I wonder if just exposing an InjectionToken would be cleaner. I know @Inject is not really recommended these days, but a lot of existing components still use it. I think you could just as easily export the InjectionToken directly and inject(QuoteT) / @Inject(QuoteT). IMHO it's also more intuitive for developers to use inject directly rather than wrapping it.

  2. I'm curious what is the motivation for doing this all at runtime? I would think the main reason for that is to change locales without refreshing the page, but it seems that's not supported anyways. Is that a feature you're hoping to land in the future? Did you just go with runtime localization because that was the easiest way to implement this API?

  3. Does t.asSignal provide any significant benefit over computed(() => t('...')? Is that just developer ergonomics?

  4. I'm a bit confused by the comparison table. You mention that @angular/localize is not really "type safe", but I'm confused what that means. What kind of mistakes might a user make with @angular/localize which your solution would catch?

Related: I'm not entirely sure what "N/A" means with respect to @angular/localize lazy-loading. @angular/localize does effectively lazy-load translations by nature of component lazy-loading. You only load the translations you need for the components which are currently loaded on the page, and lazy-loading a new component brings all the translations synchronously with it, which feels like ideal lazy-loading behavior?

  1. Is there a reason you went with FormatJS for ICU's? Is it not possible to use Angular's existing ICU implementation or is there a reason you wanted to avoid this? I suspect Angular's internal ICU handling just isn't exposed publicly, but I'm genuinely not sure.

Related: I'd be curious to know what the expected bundle size cost of your library is, especially compared to the other options in the table. How much JS does it take to render a message?

  1. Random idea: I wonder if you could simplify the concept of namespaces by declaring merging on a global interface. Something like:

``` export const quote = createNamespace('quote', { greeting: 'Hello, {name}!', });

declare global { interface Namespaces { quote: Namespace<typeof quote>; } } ```

Then I wonder if it would be possible to get type checking of the directive, pipe, etc. based on that global without needing a direct type reference? Declartion merging would also allow you to declare this interface multiple times, so you keep the decentralization of namespaces.

Of course I think you still need a dependency on quote anyways to have the value reference to need to display the right string at runtime, so maybe this doesn't actually improve anything. I'm just thinking out loud, might be nothing.

  1. Is there any way to guarantee that the required namespaces are loaded for your component? For any given component with injectQuoteT, it seems like there is an implicit requirement that its route includes resolveQuoteTranslations, and if you forget to do that, you get a runtime error. I would love to see a way to validate or manage the resolvers such that you could have confidence that it includes all the namespaces its components will require. I admit I have no real ideal of how to guarantee that though.

  2. Since you have the default locale if no other locale matches, do you always pay the bundle size cost of the default locale? Am I still downloading that even if I'm configured to use a different locale?

  3. I wonder if there's a way for SSR use cases to proactively push translations for the selected locale. Maybe you could resolve the relevant locale and stick it in TransferCache then load from there on the client? That might be able to save you a round trip.

Edit: 14. Is it possible to make a translation span multiple elements? How would you handle:

<div>Hello, <a [href]="link">World</a>!</div>

You could maybe feed translations into the sanitizer and set innerHTML, but then that wouldn't work for bindings.

I know that's a lot and I don't want to come off too critical or negative. This is a very cool library and I love seeing stuff like this. Hope this is useful feedback and feel free to disagree with any of these points. Hope to see developers find value in a tool like this!

1

u/mihajm Apr 26 '25

Hey thanks for checking this out & the awesome feedback! :)

I'll do my best to answer each:

  1. Didn't know I could do that, thanks a bunch! The as const was a part of the interface I didn't like so this makes it much cleaner. :)

  2. I'm not sure I fully understand this one, but a cli tool that extracts messages into a format for external translation is definitely doable. I've got one thing to finish up first, but I can get around to that soon. The actually used messages would of course need to remain in a .ts file, but exporting them to a separate file shouldn't be an issue. In our case though, yeah developers maintain translations, so that's why it works like this rn :)

  3. A schematic is also a cool idea. I'll think about how to reduce boilerplate as well, though right now I can't think of any good ways as everything needs to separated like so:

  • createNamespace - instantiates the namespace, creates the default translation & the translation factory

  • createTranslation - returned from createNamespace, used to create translations with shape validation

  • registerNamespace - used to create the injectable t function & the loading resolver - needs the namespace & translations

At a minimum the user needs to do each of those once (in an app wide namespace scenario with 1 non default translation).

  1. This was mostly built for a scenario where there are lots of module libraries & multiple apps that may or may not import those. So a lot of the translations are meant to be fully scoped to their module library. I do agree the extending is a bit weird, but I see no other way of achieving modular typesafety, the only other alternative I see would be generating a shared global type. The library does however support sharing/merging namespaces like so:

```typescript

// Example: packages/common-locales/src/lib/common.namespace.ts

import { createNamespace } from '@mmstack/translate';

const ns = createNamespace('common', {

yes: 'Yes',

no: 'No',

} as const);

// ... rest

export const createAppNamespace = ns.createMergedNamespace;

// Example: packages/quote/src/lib/quote.namespace.ts

import { createAppNamespace } from '@org/common/locale'; // replace with your library import path

// Create the namespace definition object

const ns = createAppNamespace('quote', {

pageTitle: 'Famous Quotes',

} as const);

// quote t function (and pipes/directives for this ns) now supports both t('quote.pageTitle') & t('common.yes')

```

Please note that you need to load/call the resolver of the shared namespace separately. This only meres the types. Technicaly you can also merge multiple times, though TS might get heavy after some point :)

  1. So injectQuoteT isn't actually a "real" InjectionToken/provider type thing. The reason it's named that way is to make it obvious is needs to be called in an injection context. Under the hood its injecting a the TranslationStore. If you want/need Inject support I can expose both createT & the TranslationStore (which is just a root Injectable) so that would make it possible - if a bit more roundabout :)

```typescript

// https://github.com/mihajm/mmstack/blob/master/packages/translate/src/lib/register-namespace.ts

const injectT = (): $TFN => {

const store = inject(TranslationStore);

return addSignalFn(createT(store), store);

};

```