Bug(transloco): translateSignal prepends DI scope to key when none is provided
Is there an existing issue for this?
- [x] I have searched the existing issues
Which Transloco package(s) are the source of the bug?
Transloco
Is this a regression?
No
Current behavior
When using translateSignal('some.key') or translateObjectSignal('some.key') without explicitly providing a scope, Transloco automatically fetches the scope from the dependency injection context (inject(TRANSLOCO_SCOPE)). If a scope (e.g., validation) is present in the DI context, it prepends this scope to the translation key, resulting in lookups like validation.some.key instead of just some.key. This is unexpected and can cause incorrect translations to be used, especially when the developer intends to use a root-level key.
Expected behavior
If no scope is provided to translateSignal or translateObjectSignal, Transloco should use the translation key as-is, without inferring or prepending any scope from the DI context. The behavior should be consistent with selectTranslate, which does not infer a scope unless one is explicitly provided.
Please provide a link to a minimal reproduction of the bug, if you won't provide a link the issue won't be handled.
https://codesandbox.io/p/devbox/quiet-snowflake-4zn2tf?workspaceId=ws_MMfF28NiXcF9q6oq2kP3fe
Transloco Config
Please provide the environment you discovered this bug in
Angular CLI: 18.2.11
Node: 22.14.0
Package Manager: yarn 4.9.1
OS: darwin x64
Angular: 18.2.9
Browser
Additional context
Please check the lazy component in Sandbox.
I would like to make a pull request for this bug
Yes π
I'm also having this issue. Translations work fine with RxJS but the signal methods don't return values at all.
The problem is that as a developer you would expect translateSignal to mirror translate/selectTranslate. However if you look closely you will see that the former uses TRANSLOCO_SCOPE for its scope parameter's default value.
https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.signal.ts#L154-L157
Meanwhile the latter use getActiveLang for their scope parameter's default value.
https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L282-L286
https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L358-L360
selectTranslate is more difficult to follow but langChanges$ does reflect the active lang.
Ideally translateSignal should be brought in line with the existing APIs.
Mayby move current imlementation to new scopedTranslate signal and mirror algorithm from selectTranslate to translateSignal.
Bdw, it's not recomended to postfix with signal.
From my investigation, to have consistent behavior between template translations (Directive and Pipes) and service translations (selectTranslate or translateSignal), it is due to logic that prefixes the scope to the key here:
https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L297
This causes what is explained in the documentation about how the scope works to not be usable when using any function that relies on this, which is very unfortunate because it is an incredible inheritance logic with the scopes.
https://github.com/jsverse/transloco/blob/4fc2651ede154fae2e2a5a1e719a14b9eceb6582/libs/transloco/src/lib/transloco.service.ts#L271-L282
For me, it would be better to have the same logic as the template translations to ensure consistency and fix the regression regarding scope inheritance or at least propose an option to have it.
Upon re-examining the merged solution, I don't think the approach is actually to resolve the root cause, which is the inconsistency between the template, signal, and service translations.
Would love to hear your thoughts about this. None should prefix right? I think this was done to mimic the template translation's auto scope loading behavior
I'd like to contribute some thoughts on the scope inference inconsistency issue and hear the community's feedback on potential solutions.
I'm not entirely sure if this is the best place to present this proposalβperhaps opening an RFC (similar to Angular's RFC process) would be more appropriate for a change of this nature. Regardless of which path we take, I'm happy to implement a pull request for whichever solution the team decides to pursue.
Current Behavior - The Problem
There's a critical inconsistency in how scope resolution works across Transloco APIs:
π΄ Service methods DON'T auto-infer scope from DI
@Component({
selector: 'app-my-component',
template: `<div>{{ text }}</div>`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
private transloco = inject(TranslocoService);
// β Service methods ignore DI scope - uses key as-is
text = this.transloco.translate('required'); // β 'required' (root key, not 'validation.required')
getText() {
// Observable version also ignores DI scope
return this.transloco.selectTranslate('required'); // β Observable<'required'>
}
getTextWithScope() {
// Must explicitly provide scope:
return this.transloco.translate('required', {}, 'validation'); // β 'validation.required'
}
}
β Directive, Pipe, and Signal correctly auto-infer scope from DI
@Component({
selector: 'app-my-component',
template: `
<div transloco="required"></div>
<div>{{ 'required' | transloco }}</div>
`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
// β
These correctly auto-infer 'validation' scope from DI
text1 = translateSignal('required'); // β 'validation.required'
text2 = translateObjectSignal('errors'); // β 'validation.errors'
}
<!-- β
These also correctly auto-infer 'validation' scope -->
<div transloco="required"></div> <!-- β 'validation.required' -->
<div>{{ 'required' | transloco }}</div> <!-- β 'validation.required' -->
π¨ The Inconsistency Problem
| Method | Auto-infers from DI? | Current Behavior |
|---|---|---|
service.translate() |
β No | Uses key as-is (inconsistent) |
service.selectTranslate() |
β No | Uses key as-is (inconsistent) |
translateSignal() |
β Yes | Auto-prefixes with DI scope |
translateObjectSignal() |
β Yes | Auto-prefixes with DI scope |
transloco directive |
β Yes | Auto-prefixes with DI scope |
transloco pipe |
β Yes | Auto-prefixes with DI scope |
Real-world confusion:
@Component({
selector: 'app-my-component',
template: `<div>{{ displayText() }}</div>`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
private transloco = inject(TranslocoService);
// Same key, different results - WHY?
text1 = this.transloco.translate('required'); // β 'required' β
text2 = translateSignal('required'); // β 'validation.required' β
text3 = this.transloco.selectTranslate('required'); // β Observable<'required'> β
displayText = this.text2;
}
@Component({
selector: 'app-my-component',
template: `
<!-- Template correctly uses scope, but service doesn't! -->
<div transloco="required"></div> <!-- β 'validation.required' β
-->
<button (click)="doSomething()">
{{ transloco.translate('required') }} <!-- β 'required' β Wrong! -->
</button>
`
})
export class MyComponent {
transloco = inject(TranslocoService);
doSomething() {
console.log(this.transloco.translate('required')); // β 'required' β
}
}
When accessing root keys from a scoped component:
@Component({
selector: 'app-admin',
template: `
<!-- Accessing root keys in template is confusing -->
<h1 transloco="app.title"></h1> <!-- β β 'admin.app.title' (doesn't exist) -->
<h1 transloco="app.title" translocoScope=""></h1> <!-- β
β 'app.title' (unintuitive) -->
`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
private transloco = inject(TranslocoService);
// Accessing root translation keys:
// Service works without explicit scope
appTitle1 = this.transloco.translate('app.title'); // β
Works β 'app.title'
// Signals/directives/pipes require workaround with explicit empty scope
appTitle2 = translateSignal('app.title'); // β Fails β 'admin.app.title' (doesn't exist)
appTitle3 = translateSignal('app.title', {}, ''); // β
Works but unintuitive
}
Proposed Solution: New inferScopeFromDI Config
Add a new configuration option to make service methods consistent with directives, pipes, and signals:
export interface TranslocoConfig {
// ... existing config
scopes?: {
autoPrefixKeys?: boolean; // existing - controls service prefixing
inferScopeFromDI?: boolean; // NEW - controls DI scope inference (default: true)
};
}
Why Default to true?
- Consistency: Makes service methods behave like directives/pipes/signals
- Least Surprise: Most users expect scoped components to use their scope
- Backward Compatible: Doesn't break existing directive/pipe/signal usage
- Intuitive:
TRANSLOCO_SCOPEprovider should affect all translation methods
Resulting Behavior
With inferScopeFromDI: true (Recommended Default) β
Consistent behavior across ALL APIs - service now matches directives/pipes/signals:
// transloco.config.ts
export const translocoConfig = {
scopes: {
inferScopeFromDI: true // Default - all methods respect DI scope
}
};
@Component({
selector: 'app-my-component',
template: `
<div>{{ text1 }}</div>
<div>{{ text2() }}</div>
`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
private transloco = inject(TranslocoService);
// β
ALL methods now consistently use DI scope
text1 = this.transloco.translate('required'); // β 'validation.required' β
text2 = translateSignal('required'); // β 'validation.required' β
text3 = this.transloco.selectTranslate('required'); // β Observable<'validation.required'> β
}
<!-- β
All use DI scope consistently -->
<div transloco="required"></div> <!-- β 'validation.required' -->
<div>{{ 'required' | transloco }}</div> <!-- β 'validation.required' -->
When accessing root keys, use empty scope explicitly:
@Component({
selector: 'app-admin',
template: `
<!-- Access root keys with empty scope -->
<h1 transloco="app.title" translocoScope=""></h1> <!-- β 'app.title' -->
`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
private transloco = inject(TranslocoService);
// Access root keys by passing empty scope
appTitle1 = this.transloco.translate('app.title', {}, ''); // β 'app.title'
appTitle2 = translateSignal('app.title', {}, ''); // β 'app.title'
}
With inferScopeFromDI: false (Explicit Opt-Out)
All methods ignore DI scope - must be explicit everywhere:
export const translocoConfig = {
scopes: {
inferScopeFromDI: false // Opt-out: explicit scope required everywhere
}
};
@Component({
selector: 'app-my-component',
template: `<div>{{ text1 }}</div>`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'validation' }]
})
export class MyComponent {
private transloco = inject(TranslocoService);
// All use keys as-is - DI scope is ignored
text1 = this.transloco.translate('required'); // β 'required'
text2 = translateSignal('required'); // β 'required'
text3 = this.transloco.selectTranslate('required'); // β Observable<'required'>
// Must explicitly provide scope when needed
text4 = this.transloco.translate('required', {}, 'validation'); // β 'validation.required'
text5 = translateSignal('required', {}, 'validation'); // β 'validation.required'
}
<!-- Must explicitly provide scope -->
<div transloco="required"></div> <!-- β 'required' -->
<div transloco="required" translocoScope="validation"></div> <!-- β 'validation.required' -->
<div>{{ 'required' | transloco:{}:'validation' }}</div> <!-- β 'validation.required' -->
Benefits
β
Consistency: Service methods now behave the same as directives/pipes/signals
β
Predictability: All methods respect TRANSLOCO_SCOPE provider by default
β
Intuitive: Providing a scope actually works everywhere (principle of least surprise)
β
Backward Compatible: Directives/pipes/signals continue working as before
β
Flexible: Can opt-out with inferScopeFromDI: false when needed
β
Better DX: No more confusion about why service methods ignore scope providers
β
Cleaner Code: Less need for explicit scope parameters in every call (DRY principle)
Questions and Alternative Approach
π€ Do autoPrefixKeys and inferScopeFromDI solve the same problem?
Both configs control whether scopes should be automatically applied to translation keys:
autoPrefixKeys: Controls if the service should prefix keys with scope when scope is explicitly providedinferScopeFromDI: Controls if methods should infer scope from DI context when not explicitly provided
Question: Are these really two separate concerns, or just different aspects of the same problem?
Perhaps a single, clearer config would be better:
scopes?: {
autoResolve?: 'never' | 'explicit' | 'infer'; // Default: 'infer'
}
Where:
'never': Never auto-prefix, always use keys as-is'explicit': Only prefix when scope is explicitly passed as parameter'infer': Prefix with scope from DI context (current directive/pipe/signal behavior)
π‘ Better Solution: Fallback to Root Keys
The ideal behavior when TRANSLOCO_SCOPE is provided should be:
- Try scoped key first:
validation.required - If not found, fallback to root key:
required
This would solve the root key access problem naturally:
@Component({
selector: 'app-admin',
template: `
<!-- No need for empty scope workaround -->
<h1 transloco="app.title"></h1> <!-- β Tries 'admin.app.title', falls back to 'app.title' β
-->
<div transloco="required"></div> <!-- β Finds 'admin.required' directly β
-->
`,
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }]
})
export class AdminComponent {
private transloco = inject(TranslocoService);
// Natural behavior - no workarounds needed
appTitle = this.transloco.translate('app.title'); // β Tries 'admin.app.title', falls back to 'app.title' β
required = this.transloco.translate('required'); // β Finds 'admin.required' β
}
Benefits of fallback approach:
β
No workarounds needed: Access root keys naturally from scoped components
β
Scope isolation: Scoped translations take precedence when they exist
β
Graceful degradation: Falls back to root when scoped translation doesn't exist
β
Simpler mental model: "Try scoped, then global" is intuitive
β
No config complexity: Single behavior that works for all cases
β
DRY: Reuse common translations without duplicating in every scope
Implementation suggestion:
// Translation resolution order with TRANSLOCO_SCOPE='admin'
translate('title') {
1. Look for 'admin.title' // β
Found? Return it
2. Look for 'title' // β
Found? Return it
3. Return missing key handler // β Not found in either
}
This approach eliminates the need for both autoPrefixKeys and inferScopeFromDI configs while providing the most intuitive behavior.
Summary
The proposed inferScopeFromDI config solves the inconsistency by:
- Default
true: Makes service methods consistent with directives/pipes/signals (KISS) - Fixes the bug: Service methods now respect
TRANSLOCO_SCOPEprovider - Backward compatible: Existing directive/pipe/signal code continues working unchanged
- Optional opt-out: Set
falsefor explicit scope control when needed (DRY) - Clear mental model:
TRANSLOCO_SCOPEprovider works everywhere or nowhere
The Core Problem Being Solved
Before: Why does TRANSLOCO_SCOPE work in templates but not in service calls?
After: TRANSLOCO_SCOPE consistently affects all translation methods
However, the fallback approach may be even better: automatically trying root keys when scoped keys don't exist would eliminate the need for workarounds entirely while maintaining scope isolation where it matters.
This aligns Transloco's behavior with the principle of least surprise: when you provide a scope, all translation methods within that scope use it by default.
I'm of the mindset that auto scope isn't intelligent enough to infer which scope I actually mean, especially for keys that exist simultaneously between multiple scopes and components that use multiple scopes, so I just end up explicitly defining the scope anyway. Therefore I would vote to have all of the auto scoping disabled by default across the APIs.
Personally, I think automatically inferring the scope is a big usability plus when using the signal, directive or pipe API. I do see you are recommending the fallback option to use the non-scoped key, but this could end up being a breaking change for some users as well that rely on a translation not being found in their scoped translation context.
Secondly, I don't believe this functionality is actually possible in Angular because the TranslocoService is injected at the root of the application and does not have access to TRANSLOCO_SCOPE instances provided at lower levels of the DI tree.
text1 = this.transloco.translate('required'); // β 'required'
text2 = translateSignal('required'); // β 'validation.required'
text3 = this.transloco.selectTranslate('required'); // β Observable<'required'>
Using the TranslocoService directly is more of an advanced use case in my opinion. If you want to always get automatic DI scoping, users should only use the signal, directive, or pipe APIs.
Thank you guys for sharing. I think I'll turn this into an actual RFC about this topic to get the community's thoughts on this before making a decision