Skip to content

Commit 1f38b6b

Browse files
committed
feat(types): add branded types for marking strings as unlocalized
- UnlocalizedFunction<T> wraps objects to ignore all string arguments - unlocalized() helper for automatic type inference - UnlocalizedText, UnlocalizedLog, UnlocalizedStyle, UnlocalizedClassName - UnlocalizedEvent for analytics, UnlocalizedKey for storage keys - Detects __linguiIgnore and __linguiIgnoreArgs brands via TypeScript
1 parent e809a07 commit 1f38b6b

File tree

5 files changed

+843
-1
lines changed

5 files changed

+843
-1
lines changed

docs/rules/no-unlocalized-strings.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,171 @@ const arrows = "→ ← ↑ ↓"
358358

359359
This uses Unicode letter detection (`\p{L}`), so accented characters like `ä`, `ö`, `ü`, `é` are correctly recognized as letters.
360360

361+
## Branded Types
362+
363+
This plugin exports branded types that you can use to mark parameters or properties as "no translation needed". The rule automatically detects these types and ignores strings passed to them.
364+
365+
### Installation
366+
367+
Import the types from the plugin:
368+
369+
```ts
370+
import type {
371+
UnlocalizedFunction,
372+
UnlocalizedText,
373+
UnlocalizedLog,
374+
UnlocalizedStyle,
375+
UnlocalizedClassName,
376+
UnlocalizedEvent,
377+
UnlocalizedKey,
378+
} from "eslint-plugin-lingui-typescript/types"
379+
```
380+
381+
### Available Types
382+
383+
| Type | Use Case |
384+
|------|----------|
385+
| `UnlocalizedFunction<T>` | Wrap entire function/object to ignore all string arguments |
386+
| `UnlocalizedText` | Generic catch-all for technical strings |
387+
| `UnlocalizedLog` | Logger message parameters (string only) |
388+
| `UnlocalizedStyle` | Style values (colors, fonts, spacing) |
389+
| `UnlocalizedClassName` | CSS class name strings |
390+
| `UnlocalizedEvent` | Analytics/tracking event names |
391+
| `UnlocalizedKey` | Storage keys, query keys, identifiers |
392+
393+
### Example: Custom Logger (Recommended)
394+
395+
Use the `unlocalized()` helper function to wrap logger objects. This is the most flexible approach - all string arguments to any method are automatically ignored, and TypeScript infers the type automatically.
396+
397+
```ts
398+
import { unlocalized } from "eslint-plugin-lingui-typescript/types"
399+
400+
function createLogger(prefix = "[App]") {
401+
return unlocalized({
402+
debug: (...args: unknown[]) => console.debug(prefix, ...args),
403+
info: (...args: unknown[]) => console.info(prefix, ...args),
404+
warn: (...args: unknown[]) => console.warn(prefix, ...args),
405+
error: (...args: unknown[]) => console.error(prefix, ...args),
406+
})
407+
}
408+
409+
// Automatically typed - no manual type annotation needed!
410+
const logger = createLogger()
411+
412+
// All string arguments are now ignored
413+
logger.info("Server started on port", 3000) // ✅ Not flagged
414+
logger.error("Connection failed:", error) // ✅ Not flagged
415+
logger.debug({ request }, "received") // ✅ Not flagged
416+
```
417+
418+
Alternatively, you can use `UnlocalizedFunction<T>` directly:
419+
420+
```ts
421+
import type { UnlocalizedFunction } from "eslint-plugin-lingui-typescript/types"
422+
423+
interface Logger {
424+
debug(...args: unknown[]): void
425+
info(...args: unknown[]): void
426+
}
427+
428+
// Option A: Type the variable
429+
const logger: UnlocalizedFunction<Logger> = createLogger()
430+
431+
// Option B: Type the factory return
432+
function createLogger(): UnlocalizedFunction<Logger> { ... }
433+
```
434+
435+
### Example: Custom Logger (String Parameters Only)
436+
437+
If your logger only accepts strings, you can use `UnlocalizedLog` for individual parameters:
438+
439+
```ts
440+
import type { UnlocalizedLog } from "eslint-plugin-lingui-typescript/types"
441+
442+
interface Logger {
443+
debug(message: UnlocalizedLog): void
444+
info(message: UnlocalizedLog): void
445+
warn(message: UnlocalizedLog): void
446+
error(message: UnlocalizedLog): void
447+
}
448+
449+
const logger: Logger = createLogger()
450+
logger.info("Starting server on port 3000") // ✅ Not flagged
451+
logger.error("Database connection failed") // ✅ Not flagged
452+
```
453+
454+
### Example: Analytics/Tracking
455+
456+
```ts
457+
import type { UnlocalizedEvent } from "eslint-plugin-lingui-typescript/types"
458+
459+
interface Analytics {
460+
track(event: UnlocalizedEvent, data?: object): void
461+
page(name: UnlocalizedEvent): void
462+
}
463+
464+
analytics.track("User Signed Up") // ✅ Not flagged
465+
analytics.track("Purchase Completed") // ✅ Not flagged
466+
```
467+
468+
### Example: Storage Keys
469+
470+
```ts
471+
import type { UnlocalizedKey } from "eslint-plugin-lingui-typescript/types"
472+
473+
interface Storage {
474+
get(key: UnlocalizedKey): string | null
475+
set(key: UnlocalizedKey, value: string): void
476+
}
477+
478+
storage.get("User Preferences") // ✅ Not flagged
479+
storage.set("Auth Token", token) // ✅ Not flagged
480+
```
481+
482+
### Example: Theme Configuration
483+
484+
```ts
485+
import type { UnlocalizedStyle } from "eslint-plugin-lingui-typescript/types"
486+
487+
interface ThemeConfig {
488+
primaryColor: UnlocalizedStyle
489+
fontFamily: UnlocalizedStyle
490+
spacing: UnlocalizedStyle
491+
}
492+
493+
const theme: ThemeConfig = {
494+
primaryColor: "#3b82f6", // ✅ Not flagged
495+
fontFamily: "Inter, sans-serif", // ✅ Not flagged
496+
spacing: "1rem", // ✅ Not flagged
497+
}
498+
```
499+
500+
### Example: Component Props
501+
502+
```ts
503+
import type { UnlocalizedClassName } from "eslint-plugin-lingui-typescript/types"
504+
505+
interface ButtonProps {
506+
className?: UnlocalizedClassName
507+
iconClassName?: UnlocalizedClassName
508+
children: React.ReactNode
509+
}
510+
511+
<Button className="px-4 py-2 bg-blue-500"> // ✅ Not flagged
512+
<Trans>Click me</Trans>
513+
</Button>
514+
```
515+
516+
### How It Works
517+
518+
These types use TypeScript's branded type pattern:
519+
520+
```ts
521+
type UnlocalizedLog = string & { readonly __linguiIgnore?: "UnlocalizedLog" }
522+
```
523+
524+
The `__linguiIgnore` property is a phantom type marker—it never exists at runtime. The rule checks for this property in the contextual type to determine if a string should be ignored.
525+
361526
## When Not To Use It
362527

363528
This rule requires TypeScript with type-aware linting enabled. If your project doesn't use TypeScript or doesn't need localization, disable this rule.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
".": {
1818
"types": "./dist/index.d.ts",
1919
"import": "./dist/index.js"
20+
},
21+
"./types": {
22+
"types": "./dist/types.d.ts",
23+
"import": "./dist/types.js"
2024
}
2125
},
2226
"files": [

src/rules/no-unlocalized-strings.test.ts

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,185 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
391391

392392
// Import/Export paths
393393
{ code: 'import name from "hello"', filename: "test.tsx" },
394-
{ code: 'export * from "hello_export_all"', filename: "test.tsx" }
394+
{ code: 'export * from "hello_export_all"', filename: "test.tsx" },
395+
396+
// =========================================================================
397+
// Branded types with __linguiIgnore
398+
// =========================================================================
399+
400+
// UnlocalizedLog branded type
401+
{
402+
code: `
403+
type UnlocalizedLog = string & { readonly __linguiIgnore?: "UnlocalizedLog" }
404+
interface Logger {
405+
info(message: UnlocalizedLog): void
406+
}
407+
declare const logger: Logger
408+
logger.info("Starting server on port 3000")
409+
`,
410+
filename: "test.tsx"
411+
},
412+
413+
// UnlocalizedStyle branded type
414+
{
415+
code: `
416+
type UnlocalizedStyle = string & { readonly __linguiIgnore?: "UnlocalizedStyle" }
417+
interface ThemeConfig {
418+
primaryColor: UnlocalizedStyle
419+
fontFamily: UnlocalizedStyle
420+
}
421+
const theme: ThemeConfig = {
422+
primaryColor: "#3b82f6",
423+
fontFamily: "Inter, sans-serif"
424+
}
425+
`,
426+
filename: "test.tsx"
427+
},
428+
429+
// UnlocalizedClassName branded type
430+
{
431+
code: `
432+
type UnlocalizedClassName = string & { readonly __linguiIgnore?: "UnlocalizedClassName" }
433+
interface ButtonProps {
434+
className?: UnlocalizedClassName
435+
}
436+
function Button(props: ButtonProps) {}
437+
<Button className="px-4 py-2 bg-blue-500" />
438+
`,
439+
filename: "test.tsx"
440+
},
441+
442+
// UnlocalizedText branded type (catch-all)
443+
{
444+
code: `
445+
type UnlocalizedText = string & { readonly __linguiIgnore?: "UnlocalizedText" }
446+
interface ApiConfig {
447+
endpoint: UnlocalizedText
448+
}
449+
const config: ApiConfig = {
450+
endpoint: "Users endpoint configuration"
451+
}
452+
`,
453+
filename: "test.tsx"
454+
},
455+
456+
// UnlocalizedEvent branded type
457+
{
458+
code: `
459+
type UnlocalizedEvent = string & { readonly __linguiIgnore?: "UnlocalizedEvent" }
460+
interface Analytics {
461+
track(event: UnlocalizedEvent): void
462+
}
463+
declare const analytics: Analytics
464+
analytics.track("User Signed Up")
465+
`,
466+
filename: "test.tsx"
467+
},
468+
469+
// UnlocalizedKey branded type
470+
{
471+
code: `
472+
type UnlocalizedKey = string & { readonly __linguiIgnore?: "UnlocalizedKey" }
473+
interface Storage {
474+
get(key: UnlocalizedKey): string | null
475+
}
476+
declare const storage: Storage
477+
storage.get("User Preferences")
478+
`,
479+
filename: "test.tsx"
480+
},
481+
482+
// Branded type with function parameter
483+
{
484+
code: `
485+
type UnlocalizedLog = string & { readonly __linguiIgnore?: "UnlocalizedLog" }
486+
function log(msg: UnlocalizedLog) {}
487+
log("Application started successfully")
488+
`,
489+
filename: "test.tsx"
490+
},
491+
492+
// Branded type with multiple methods (Logger pattern)
493+
{
494+
code: `
495+
type UnlocalizedLog = string & { readonly __linguiIgnore?: "UnlocalizedLog" }
496+
interface Logger {
497+
debug(message: UnlocalizedLog, ...args: unknown[]): void
498+
info(message: UnlocalizedLog, ...args: unknown[]): void
499+
warn(message: UnlocalizedLog, ...args: unknown[]): void
500+
error(message: UnlocalizedLog, ...args: unknown[]): void
501+
}
502+
declare const logger: Logger
503+
logger.debug("Debug information here")
504+
logger.info("Processing request now")
505+
logger.warn("This might be problematic")
506+
logger.error("Something went wrong!")
507+
`,
508+
filename: "test.tsx"
509+
},
510+
511+
// UnlocalizedFunction<T> - wrap entire logger interface
512+
{
513+
code: `
514+
type UnlocalizedFunction<T> = T & { readonly __linguiIgnoreArgs?: true }
515+
interface Logger {
516+
info(...args: unknown[]): void
517+
debug(...args: unknown[]): void
518+
}
519+
declare const logger: UnlocalizedFunction<Logger>
520+
logger.info("Server started on port", 3000)
521+
logger.info("Request received")
522+
logger.debug({ complex: "object" }, "with message")
523+
`,
524+
filename: "test.tsx"
525+
},
526+
527+
// UnlocalizedFunction<T> - with factory function return type
528+
{
529+
code: `
530+
type UnlocalizedFunction<T> = T & { readonly __linguiIgnoreArgs?: true }
531+
interface Logger {
532+
info(...args: unknown[]): void
533+
warn(...args: unknown[]): void
534+
error(...args: unknown[]): void
535+
}
536+
declare function createLogger(): UnlocalizedFunction<Logger>
537+
const logger = createLogger()
538+
logger.info("Application started successfully")
539+
logger.warn("This might be a problem")
540+
logger.error("Something went wrong!")
541+
`,
542+
filename: "test.tsx"
543+
},
544+
545+
// UnlocalizedFunction<T> - direct function
546+
{
547+
code: `
548+
type UnlocalizedFunction<T> = T & { readonly __linguiIgnoreArgs?: true }
549+
type LogFn = (...args: unknown[]) => void
550+
declare const log: UnlocalizedFunction<LogFn>
551+
log("Direct function call ignored")
552+
`,
553+
filename: "test.tsx"
554+
},
555+
556+
// unlocalized() helper function
557+
{
558+
code: `
559+
type UnlocalizedFunction<T> = T & { readonly __linguiIgnoreArgs?: true }
560+
function unlocalized<T>(value: T): UnlocalizedFunction<T> { return value }
561+
function createLogger() {
562+
return unlocalized({
563+
info: (...args: unknown[]) => console.info(...args),
564+
error: (...args: unknown[]) => console.error(...args),
565+
})
566+
}
567+
const logger = createLogger()
568+
logger.info("Server started successfully")
569+
logger.error("Connection failed!")
570+
`,
571+
filename: "test.tsx"
572+
}
395573
],
396574
invalid: [
397575
// Plain string that looks like UI text
@@ -583,6 +761,17 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
583761
}`,
584762
filename: "test.tsx",
585763
errors: [{ messageId: "unlocalizedString" }]
764+
},
765+
766+
// Branded types without __linguiIgnore should still be flagged
767+
{
768+
code: `
769+
type CustomString = string & { readonly __custom?: "test" }
770+
function test(msg: CustomString) {}
771+
test("Hello World")
772+
`,
773+
filename: "test.tsx",
774+
errors: [{ messageId: "unlocalizedString" }]
586775
}
587776
]
588777
})

0 commit comments

Comments
 (0)