intl-schematic
implements a comprehensive plugin system that allows to expand its functionality by hooking into the translator function.
All plugins are aware of each other at runtime and retain a mutable context to exchange information.
A plugin is a simple object with the following properties:
name
- a unique (in the context of its usage) name for the plugin;match(value)
- a method that defines if a currently processed key-value pair is suitable for the plugintranslate()
- a method that can return a string translated by the plugininfo
- any information that the plugin might need or provide to other plugins at runtime
Most plugins, however, should be created using the createPlugin
function:
import { createPlugin } from 'intl-schematic/plugins';
const MyPlugin = createPlugin(
// First parameter defines the name
'MyPlugin',
// Second parameter defines the match function
(value): value is unknown => true,
// Third parameter defines everything else
{
// Plugin info
info: { myPlugin: "it's my plugin!" },
// translate method
translate(...args: unknown[]): string | undefined {
// context-aware
this.key
this.value
this.doc
this.plugins
return 'string' || undefined;
}
}
);
This function drastically simplifies type-checking and allows to define name
and match
without using object property names.
If any additional parameters are required for the plugin,
it can instead be defined as a function that calls createPlugin
internally:
const MyPlugin = (customInfo: any) => createPlugin(
'MyPlugin',
(value): value is unknown => true,
{
// Plugin info now contains customInfo
info: { myPlugin: "it's my plugin!", customInfo },
translate(...args: unknown[]) {
// Use customInfo in the translation function
console.log(customInfo);
// ... translation logic
}
}
);
const t = createTranslator(getDocument, [
// Simply invoke the plugin as a function
// and pass the needed parameters
MyPlugin({ custom: 'my custom info' })
]);
string
Should be unique in the context of plugin usage.
However, two plugins with the same name
can co-exist,
they even can be used in the same translator function:
import { createTranslator } from 'intl-schematic';
import { createPlugin } from 'intl-schematic/plugins';
const Plugin1 = createPlugin('Plugin', () => true, { translate: () => '1' });
const Plugin2 = createPlugin('Plugin', () => true, { translate: () => '2' });
const t = createTranslator(() => ({ 'key': 'value' }), [
Plugin1,
Plugin2,
]);
// Will always return '1'
t('key'); // '1'
// because Plugin1 always matches to `true` before Plugin2 can be invoked
But when using the plugin context,
Plugin2
will always override Plugin1
, because they have the same name:
const Plugin3 = createPlugin('OtherPlugin', () => true, {
translate() {
// `Plugin` here is the name of both Plugin1 and Plugin2
console.log(this.plugins.Plugin.translate());
// => '2'
// Because Plugin2 was registered later and now overrides Plugin1
}
});
(value: unknown, key: string, document: Record<string, unknown>) => value is TypeMatch
A type-guard
function that determines if the plugin should be applied to a certain value
.
For now, only matching the value (not the key) is supported, but both key and document are provided just in case.
If a specific type is provided in the type guard clause,
intl-schematic
will recognize that the plugin is used
and type-check the translation function parameters accordingly.
See advanced type checking for details.
unknown
This property is exactly what enables the locale plugin to work.
It allows plugins to provide any information at runtime to other plugins (as well as to itself), via accessing the plugin by its name:
createPlugin('PluginName', match, {
info: { some: 'info' },
translate() {
this.plugins.PluginName.info // { some: 'info' }
}
});
See "Using other plugins" for more details.
function translate(this: PluginContext<TypeMatch>, ...args: unknown[]): string | undefined;
Allows to apply custom translation logic for the matched value (not the key, yet).
Can return either a string or undefined
.
If a string is returned, it is immediately assumed to be the result of custom translation logic,
and will be returned directly from the translator function.
If undefined
is returned, other matched plugins will be applied to the string
For the full type definition, see the
PluginContext
typeinterface PluginContext<TypeMatch> { // Current plugin's name name: string; // Currently requested key key: string; // Value found by the key, // type-matched using the aforementioned `match` function value: TypeMatch; // The entire translation document doc: Record<string, unknown>; // Context-aware translator function, // allows for recursive translations translate(key: string, ...args: unknown[]): string; // Collection of all used plugins mapped by their `name` property, // all used plugins' `match`, `translate`, and `info` // can all be accessed using this property plugins: Record<string, PluginInterface>; // Since the current plugin can be invoked on some level of recursive translation, // it may be useful to know the original translation context originalKey: string; originalValue: unknown; originalCallArgs: unknown[]; }
This context allows plugins to exchange information and invoke the translation function recursively on different keys.
For example, a plugin can call this.translate(anotherKey, ...neededArgs)
to start the translation process for any other key and get a string to return.
⚠ This may trigger infinite recursive calls if a plugin ends up calling
this.translate
on the same key, so be careful!
Example - a simple plugin that enables custom string interpolation:
// Document
const getDocument = () => ({
key: '{0} interpolated value with {1} syntax{2}'
// this allows TS to infer value types too
} as const);
const InterpolationPlugin = createPlugin(
'InterpolationPlugin',
// Detect values with the pattern of `{some-number}`, like `{0}`
(value): value is string => typeof value === 'string' && /{\d}/.test(value), {
translate(...args: string[]) {
return args.reduce(
// Index of an argument corresponds with its position in the interpolated string
(val, arg, index) => val.replace(`{${index}}`, arg),
// Note: currently processed value from context,
// equal to this.doc[this.key]
this.value
);
}
});
const t = createTranslator(getDocument, [InterpolationPlugin]);
t('key', 'Is', 'custom', ' even possible?');
// Is interpolated value with custom syntax even possible?
t('key', 'Some cool', 'custom', '!');
// Some cool interpolated value with custom syntax!
The context allows to use other plugins freely: get their information,
check if a value matches them, or even invoke their translate
function given the right parameters.
The most straightforward example of how this can be useful is the tiny locale plugin.
Normally, intl-schematic
doesn't need to be directly aware of the user's locale,
as it leaves determining the correct translation document to the developer
to supply in the getLocaleDocument
parameter.
But other plugins, such as the processors plugin, may need the current user's locale
to properly tune their functionality.
Hence, the locale plugin can request this information from the user once
and then pass it along to any other plugins that might need it.
If, instead of relying on the locale plugin for this, all plugins
that required the locale to function simply requested it from the user,
the plugins array would look like this:
const getLocale = () => new Intl.Locale(navigator.language);
const t = createTranslator(getDocument, [
Plugin1(getLocale),
Plugin2(getLocale),
ProcessorsPlugin(getLocale, processors),
// ... etc.
]);
Which is much less than ideal for developer experience.
Instead, the locale plugin effectively adds a second parameter
to the createTranslator
function, that can now be freely used by all plugins
(in the exact same way as the value of getDocument
parameter is available in the plugins' context via the doc
property),
simplifying the plguins' usage and definitions:
const t = createTranslator(getDocument, [
LocaleProviderPlugin(getLocale),
// Now these plugins do not need to require the user to pass
// `getLocale` to each of them separately,
/// and can simply call
// `this.plugins.Locale.info()`
// internally
Plugin1,
Plugin2,
ProcessorsPlugin(processors)
]);
All of the features described above provide the functionality to the plugins,
making them work and allowing to extend the features of intl-schematic
almost infinitely.
However, this is not enough for typescript to provide helpful type-hints when using the library.
In order to help typescript infer as much information as possible about the plugins that are used for a specific key,
intl-schematic
provides 2 main ways to define the needed types both declaratively and imperatively.
The match
function is typed to require
having a type predicate
in its return type, disallowing simple boolean
values entirely.
This is needed to enable compile-time type-matching for the keys,
which allows typescript to determine which plugin is the closest match for the specific key.
While the type predicate can match against something generic, like Record<string, any>
,
it can also be used to require a very specific signature for the value to be matched against.
See the processors plugin match function for a comprehensive example.
In other words, the closer the match
type predicate reflects
what the match
function actually checks for, the easier it is for typescript
to correctly detect that the plugin is used for the specific key.
Provider plugins (like the locale plugin) usually set the match
predicate
to value is never
in order to not interfere with other plugins' type matching.
If a plugin needs type-checking and auto-complete for its translation arguments to enable a smooth developer experience,
it can be registered in the PluginRegistry
interface as a PluginRecord
.
In short, the PluginRegistry
captures all registered plugins' definitions
to be placed in the context of the translator function invocation - t('specific key')
,
where additional information can be provided to the plugin:
LocaleDoc
- the current translation document;Key
- the key currently being translated (specific key
in the example above);PluginInfo
- aggregator type, contains theinfo
type of all matched plugins, can be used to infer the info for the specific plugin;ContextualPlugins
- a map of all plugins used in thet()
invocation, allows to get other plugins' type information from the registry.
A plugin is registered using an ambient module declaration for intl-schematic/plugins
declare module 'intl-schematic/plugins' {
export interface PluginRegistry<
LocaleDoc, // Translation document
Key, // Currently processed key
PluginInfo, // Current plugin info
ContextualPlugins // Map of all plugins, same as used in the `this` context
> {
PluginName: {
args: unknown[];
info?: unknown;
signature?: unknown;
}
}
}
All plugins are registered by their name
as the key in the interface, and an object value with the following properties:
args
: a named tuple, directly used as a type for thet()
function parameters after the key;- For example, if
args
is set to[arg1: string, arg2?: number]
, then thet()
call will have roughly this signature:t(key: string, arg1: string, arg2?: number)
;
- For example, if
info
: a context-defined plugin info, see the locale plugin for a simple example;signature
: any additional contextual information to display to the developer along with typehints fort()
, the plugin's signature, if you will;- Might be useful for letting the developer know about some additional context for the currently selected key - information about other keys it references, for example, or its signature in the translation document.
For the InterpolationPlugin
from the context usage example,
a plugin registry record can look like this:
// Utility types
type CreateArray<Len, El, Arr extends El[] = []> = Arr['length'] extends Len ? Arr : CreateArray<Len, El, [template: El, ...Arr]>
type Add<A extends number, B extends number> = [...CreateArray<A, 1>, ...CreateArray<B, 1>]['length'];
// Recursively counts templates in the string
type CountTemplates<Value extends string, Amount extends number = 1> =
Value extends `${string}{${number}}${infer Substring}`
? Substring extends `${string}{${number}}${string}`
? CountTemplates<Substring, Add<Amount, 1> & number>
: Amount
: false;
declare module 'intl-schematic/plugins' {
// The signature type parameters' names must match exactly to the original signature
export interface PluginRegistry<
LocaleDoc, // Translation document
Key, // Currently processed key
PluginInfo, // Current plugin info
ContextualPlugins // Map of all plugins, same as used in the `this` context
> {
// Plugin's name must match the `name` property of the plugin exactly
InterpolationPlugin: {
// Here we extract the numbers in curly braces - `/{\d+}/` - from the value
args: LocaleDoc[Key] extends infer Value
? Value extends `${string}{${number}}${string}`
// and create a string array with the length corresponding to those numbers.
? CreateArray<CountTemplates<Value>, string>
// If the amount isn't calculable, simply give unlimited arguments
: string[]
// If the pattern isn't detected - simply allow anything,
// because the plugin won't match anyway
: unknown[];
// Display to the user how many templates the key needs filled
signature: LocaleDoc[Key] extends infer Value
? Value extends `${string}{${number}}${string}`
? `${CountTemplates<Value>} templates`
: unknown
: unknown;
};
}
}
// Now we have type hints
t('key', 'Some cool', 'custom', ' and types!', '!') // TS Error: Expected 4 arguments, but got 5.
// typehint for `t` is
// `const t: <"key", "InterpolationPlugin", "3 templates">(key: "key", template: string, template_1: string, template_2: string) => string`
t('key', 'Some cool', 'custom', ' and types!')
// Some cool interpolated string with custom syntax and types!
Here, the args
property allowed us to precisely set the parameter types for t()
,
and the signature
property enabled us to display a convenient message right in the typehint for t()
!
// Typehint without custom plugin signature,
// here, the developer is forced to read the parameters' types one-by-one,
// as well as guess their meaning
const t: <"key", "InterpolationPlugin", unknown>(key: "key", template: string, template_1: string, template_2: string) => string
// Typehint with a custom plugin signature, much more convenient
const t: <"key", "InterpolationPlugin", "3 templates">(key: "key", template: string, template_1: string, template_2: string) => string
For a simple working example see the nested plugin.
For more advanced examples see the processors plugin
or the arrays plugin - which automaticaly infers
referenced keys' plugin types using the PluginRegistry
.
Notice how in all examples, all types in the PluginRegistry
are written in-line, without wrapping them in helper/utility types or interfaces.
This is done in order for typescript to correctly simplify the types before showing them to the developer, which makes type hints a lot more useful, as instead of displaying something like
type number is not assignable to type KeyParameterType<{ ... }, 'some key', { some: string }>
,
it instead simply shows
type number is not assignable to type string
.
When registering a plugin in the PluginRegistry
, there might be a need
to quickly get information about other plugins or use some handy utility types.
The intl-schematic/plugins
module provides several utility types just for this:
-
type GetPluginNameFromContext<LocaleDoc, Key, ContextualPlugins>
- Allows to detect and get the plugin name for a specific key in the translation document;
-
type KeysOfType<Object, ValueType>
- Extracts from an object all keys that have values matching the
ValueType
, allows to detect any key that would yieldtrue
for a specific plugin'smatch
function;
- Extracts from an object all keys that have values matching the
-
type PluginInterface<LocaleDoc, Key, PluginName>
- Constructs the interface for a plugin with name
PluginName
that would be used when processing the specificKey
;
- Constructs the interface for a plugin with name
-
type PluginRecord<Args, Info, Signature>
- Mainly used to quickly infer some information about the plugin without rewriting its structure in the types.
These helper types do not need importing, as they are already accessible within the intl-schematic/plugins
module declaration.
A plugin may provide a JSON-schema for a value that satisfies the match
type predicate.
By-convention, such files should be named property.schema.json
.
Here's an example of such a file provided by a plugin.
This schema is then included directly by a user into their JSON-schema for translation document validation. For more information, see the core package readme.
Even if the type isn't that complicated, the JSON-schema can still provide guidance in a form of examples or simple property descriptions.
In your plugin's root directory, create a file.
Name it property.schema.json
.
You can use this as a template:
{
"title": "Property",
"description": "This property simply allows to get the value string by the key, value can be an array to help break one-line texts in multiple lines for readability or to reference other translation propertys by their keys",
"type": []
}
Then, a user of the plugin can simply reference this schema as a schema for a property of a translation document.