From f57b91106e7916841cdbceb412b090594948c15a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Jan 2026 17:16:28 -0500 Subject: [PATCH 1/3] feat: Add support for flattened localization usage --- .../types/__tests__/localization.type.spec.ts | 91 ++++++++++++ packages/shared/src/types/clerk.ts | 8 +- packages/shared/src/types/localization.ts | 37 ++++- .../utils/__tests__/unflattenObject.spec.ts | 136 ++++++++++++++++++ packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/unflattenObject.ts | 53 +++++++ .../ui/src/localization/parseLocalization.ts | 30 +++- 7 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/types/__tests__/localization.type.spec.ts create mode 100644 packages/shared/src/utils/__tests__/unflattenObject.spec.ts create mode 100644 packages/shared/src/utils/unflattenObject.ts diff --git a/packages/shared/src/types/__tests__/localization.type.spec.ts b/packages/shared/src/types/__tests__/localization.type.spec.ts new file mode 100644 index 00000000000..e7ec3623e83 --- /dev/null +++ b/packages/shared/src/types/__tests__/localization.type.spec.ts @@ -0,0 +1,91 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import type { FlattenedLocalizationResource, LocalizationInput, LocalizationResource } from '../localization'; + +describe('FlattenedLocalizationResource', () => { + test('accepts valid flattened keys', () => { + const valid: FlattenedLocalizationResource = { + 'signIn.start.title': 'Welcome', + }; + expectTypeOf(valid).toMatchTypeOf(); + }); + + test('accepts multiple valid flattened keys', () => { + const valid: FlattenedLocalizationResource = { + 'signIn.start.title': 'Welcome', + 'signIn.start.subtitle': 'Please sign in', + 'signUp.start.title': 'Create account', + }; + expectTypeOf(valid).toMatchTypeOf(); + }); + + test('accepts top-level keys', () => { + const valid: FlattenedLocalizationResource = { + locale: 'en-US', + }; + expectTypeOf(valid).toMatchTypeOf(); + }); + + test('rejects invalid keys', () => { + const invalid: FlattenedLocalizationResource = { + // @ts-expect-error - 'invalid.key' is not a valid localization path + 'invalid.key': 'test', + }; + void invalid; + }); + + test('rejects nested structure in flattened type', () => { + // The type error occurs on the value level, not the property declaration + const invalid: FlattenedLocalizationResource = { + // @ts-expect-error - Nested objects are not valid in FlattenedLocalizationResource (values must be strings) + signIn: { start: { title: 'Welcome' } }, + }; + void invalid; + }); +}); + +describe('LocalizationInput', () => { + test('accepts nested format', () => { + const nested: LocalizationInput = { + signIn: { start: { title: 'Welcome' } }, + }; + expectTypeOf(nested).toMatchTypeOf(); + }); + + test('accepts flattened format', () => { + const flat: LocalizationInput = { + 'signIn.start.title': 'Welcome', + }; + expectTypeOf(flat).toMatchTypeOf(); + }); + + test('accepts empty object', () => { + const empty: LocalizationInput = {}; + expectTypeOf(empty).toMatchTypeOf(); + }); +}); + +describe('LocalizationResource (nested)', () => { + test('accepts valid nested structure', () => { + const nested: LocalizationResource = { + signIn: { + start: { + title: 'Welcome', + subtitle: 'Please sign in', + }, + }, + }; + expectTypeOf(nested).toMatchTypeOf(); + }); + + test('accepts partial nested structure', () => { + const partial: LocalizationResource = { + signIn: { + start: { + title: 'Welcome', + }, + }, + }; + expectTypeOf(partial).toMatchTypeOf(); + }); +}); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 314d186793b..2bc94760271 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -16,7 +16,7 @@ import type { CustomPage } from './customPages'; import type { ClerkAPIResponseError } from './errors'; import type { InstanceType } from './instance'; import type { DisplayThemeJSON } from './json'; -import type { LocalizationResource } from './localization'; +import type { LocalizationInput } from './localization'; import type { DomainOrProxyUrl, MultiDomainAndOrProxy } from './multiDomain'; import type { OAuthProvider, OAuthScope } from './oauth'; import type { OrganizationResource } from './organization'; @@ -1093,8 +1093,12 @@ export type ClerkOptions = ClerkOptionsNavigation & appearance?: any; /** * Optional object to localize your components. Will only affect [Clerk Components](https://clerk.com/docs/reference/components/overview) and not [Account Portal](https://clerk.com/docs/guides/account-portal/overview) pages. + * + * Accepts either nested format (e.g., `{ signIn: { start: { title: "..." } } }`) + * or flattened format (e.g., `{ "signIn.start.title": "..." }`). + * Cannot mix both formats in the same object. */ - localization?: LocalizationResource; + localization?: LocalizationInput; polling?: boolean; /** * By default, the last signed-in session is used during client initialization. This option allows you to override that behavior, e.g. by selecting a specific session. diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..4fe1440d2f4 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1,5 +1,5 @@ import type { FieldId } from './elementIds'; -import type { CamelToSnake, DeepPartial } from './utils'; +import type { CamelToSnake, DeepPartial, RecordToPath } from './utils'; /** * @internal @@ -65,6 +65,41 @@ type DeepLocalizationWithoutObjects = { export interface LocalizationResource extends DeepPartial> {} +/** + * All valid dot-notation paths for localization keys. + * Generated from the internal resource type using existing RecordToPath. + */ +export type LocalizationPath = RecordToPath<__internal_LocalizationResource>; + +/** + * Flattened version of LocalizationResource using dot-notation keys. + * Example: "signIn.start.title" instead of { signIn: { start: { title: ... } } } + * + * Cannot be mixed with nested format - must use one or the other. + */ +export type FlattenedLocalizationResource = { + [K in LocalizationPath]?: string; +}; + +/** + * Accepts either nested OR flattened localization format (not mixed). + * TypeScript will enforce that you use one format consistently. + * + * @example + * // Nested format (existing): + * const nested: LocalizationInput = { + * signIn: { start: { title: "Welcome back" } } + * }; + * + * @example + * // Flattened format (new): + * const flat: LocalizationInput = { + * "signIn.start.title": "Welcome back", + * "signIn.start.subtitle": "Please sign in to continue" + * }; + */ +export type LocalizationInput = LocalizationResource | FlattenedLocalizationResource; + export type __internal_LocalizationResource = { locale: string; maintenanceMode: LocalizationValue; diff --git a/packages/shared/src/utils/__tests__/unflattenObject.spec.ts b/packages/shared/src/utils/__tests__/unflattenObject.spec.ts new file mode 100644 index 00000000000..ee89ed35fd3 --- /dev/null +++ b/packages/shared/src/utils/__tests__/unflattenObject.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest'; + +import { isFlattenedObject, isNestedObject, unflattenObject, validateLocalizationFormat } from '../unflattenObject'; + +describe('isFlattenedObject', () => { + test('returns true for objects with dot-notation keys', () => { + expect(isFlattenedObject({ 'a.b.c': 'value' })).toBe(true); + }); + + test('returns false for objects without dot-notation keys', () => { + expect(isFlattenedObject({ a: { b: 'value' } })).toBe(false); + }); + + test('returns false for empty objects', () => { + expect(isFlattenedObject({})).toBe(false); + }); + + test('returns true for mixed objects with at least one dot key', () => { + expect(isFlattenedObject({ simple: 'value', 'a.b': 'other' })).toBe(true); + }); +}); + +describe('isNestedObject', () => { + test('returns true for objects with nested object values', () => { + expect(isNestedObject({ a: { b: 'value' } })).toBe(true); + }); + + test('returns false for flat objects with primitive values', () => { + expect(isNestedObject({ 'a.b': 'value' })).toBe(false); + }); + + test('returns false for empty objects', () => { + expect(isNestedObject({})).toBe(false); + }); + + test('returns false for objects with only primitive values', () => { + expect(isNestedObject({ a: 'string', b: 123, c: true })).toBe(false); + }); + + test('returns false for objects with array values', () => { + expect(isNestedObject({ a: [1, 2, 3] })).toBe(false); + }); + + test('returns false for objects with null values', () => { + expect(isNestedObject({ a: null })).toBe(false); + }); +}); + +describe('validateLocalizationFormat', () => { + test('does not throw for pure nested format', () => { + expect(() => validateLocalizationFormat({ a: { b: 'value' } })).not.toThrow(); + }); + + test('does not throw for pure flattened format', () => { + expect(() => validateLocalizationFormat({ 'a.b': 'value' })).not.toThrow(); + }); + + test('throws for mixed format', () => { + expect(() => + validateLocalizationFormat({ + a: { b: 'nested' }, + 'c.d': 'flat', + }), + ).toThrow('cannot mix nested and flattened formats'); + }); + + test('does not throw for empty object', () => { + expect(() => validateLocalizationFormat({})).not.toThrow(); + }); + + test('does not throw for objects with only primitive values (no nesting)', () => { + expect(() => validateLocalizationFormat({ a: 'string', b: 'other' })).not.toThrow(); + }); +}); + +describe('unflattenObject', () => { + test('converts flat keys to nested structure', () => { + const flat = { 'a.b.c': 'value' }; + expect(unflattenObject(flat)).toEqual({ a: { b: { c: 'value' } } }); + }); + + test('handles multiple keys at same level', () => { + const flat = { 'a.b': '1', 'a.c': '2' }; + expect(unflattenObject(flat)).toEqual({ a: { b: '1', c: '2' } }); + }); + + test('handles top-level keys without dots', () => { + const flat = { simple: 'value' }; + expect(unflattenObject(flat)).toEqual({ simple: 'value' }); + }); + + test('handles empty object', () => { + expect(unflattenObject({})).toEqual({}); + }); + + test('handles deeply nested paths', () => { + const flat = { 'a.b.c.d.e': 'deep' }; + expect(unflattenObject(flat)).toEqual({ a: { b: { c: { d: { e: 'deep' } } } } }); + }); + + test('handles real-world localization keys', () => { + const flat = { + 'signIn.start.title': 'Welcome back', + 'signIn.start.subtitle': 'Please sign in to continue', + 'signUp.start.title': 'Create an account', + }; + expect(unflattenObject(flat)).toEqual({ + signIn: { + start: { + title: 'Welcome back', + subtitle: 'Please sign in to continue', + }, + }, + signUp: { + start: { + title: 'Create an account', + }, + }, + }); + }); + + test('handles mixed simple and nested keys', () => { + const flat = { + locale: 'en', + 'signIn.start.title': 'Welcome', + }; + expect(unflattenObject(flat)).toEqual({ + locale: 'en', + signIn: { + start: { + title: 'Welcome', + }, + }, + }); + }); +}); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 4c1e6ec6bef..80e73e8c07d 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './runtimeEnvironment'; export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; +export { isFlattenedObject, isNestedObject, validateLocalizationFormat, unflattenObject } from './unflattenObject'; diff --git a/packages/shared/src/utils/unflattenObject.ts b/packages/shared/src/utils/unflattenObject.ts new file mode 100644 index 00000000000..2a18ecddd64 --- /dev/null +++ b/packages/shared/src/utils/unflattenObject.ts @@ -0,0 +1,53 @@ +/** + * Detects if an object uses flattened format (has dot-notation keys). + */ +export function isFlattenedObject(obj: Record): boolean { + return Object.keys(obj).some(key => key.includes('.')); +} + +/** + * Detects if an object uses nested format (has object values). + */ +export function isNestedObject(obj: Record): boolean { + return Object.values(obj).some(value => typeof value === 'object' && value !== null && !Array.isArray(value)); +} + +/** + * Validates that the localization object doesn't mix formats. + * Throws a descriptive error if mixing is detected. + */ +export function validateLocalizationFormat(obj: Record): void { + const hasFlattened = isFlattenedObject(obj); + const hasNested = isNestedObject(obj); + + if (hasFlattened && hasNested) { + throw new Error( + 'Clerk: Localization object cannot mix nested and flattened formats. ' + + 'Use either nested format ({ signIn: { start: { title: "..." } } }) ' + + 'or flattened format ({ "signIn.start.title": "..." }), but not both.', + ); + } +} + +/** + * Converts a flattened object to nested format. + * { "a.b.c": "value" } => { a: { b: { c: "value" } } } + */ +export function unflattenObject>(flat: Record): T { + const result: Record = {}; + + for (const [key, value] of Object.entries(flat)) { + const parts = key.split('.'); + let current = result; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + current[part] = current[part] || {}; + current = current[part] as Record; + } + + current[parts[parts.length - 1]] = value; + } + + return result as T; +} diff --git a/packages/ui/src/localization/parseLocalization.ts b/packages/ui/src/localization/parseLocalization.ts index 7bb446f1b7f..b3a57bdd2a7 100644 --- a/packages/ui/src/localization/parseLocalization.ts +++ b/packages/ui/src/localization/parseLocalization.ts @@ -1,22 +1,42 @@ -import type { DeepPartial, LocalizationResource } from '@clerk/shared/types'; -import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; +import type { LocalizationInput, LocalizationResource } from '@clerk/shared/types'; +import { + fastDeepMergeAndReplace, + isFlattenedObject, + unflattenObject, + validateLocalizationFormat, +} from '@clerk/shared/utils'; import { dequal as deepEqual } from 'dequal'; import { useOptions } from '../contexts'; import { defaultResource } from './defaultEnglishResource'; let cache: LocalizationResource | undefined; -let prev: DeepPartial | undefined; +let prev: LocalizationInput | undefined; const parseLocalizationResource = ( - userDefined: DeepPartial, + userDefined: LocalizationInput, base: LocalizationResource, ): LocalizationResource => { if (!cache || (!!prev && prev !== userDefined && !deepEqual(userDefined, prev))) { prev = userDefined; + + // If no user-defined localization, just return base + if (!userDefined || Object.keys(userDefined).length === 0) { + cache = base; + return cache; + } + + // Validate no mixing of formats (throws if mixed) + validateLocalizationFormat(userDefined as Record); + + // Convert flattened to nested if needed + const normalized = isFlattenedObject(userDefined as Record) + ? unflattenObject(userDefined as Record) + : (userDefined as LocalizationResource); + const res = {} as LocalizationResource; fastDeepMergeAndReplace(base, res); - fastDeepMergeAndReplace(userDefined, res); + fastDeepMergeAndReplace(normalized, res); cache = res; return cache; } From 823ba34d8ff83384ccdf984dda2f0fec4c41c428 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Jan 2026 17:40:28 -0500 Subject: [PATCH 2/3] simplify --- .../ui/src/localization/parseLocalization.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/localization/parseLocalization.ts b/packages/ui/src/localization/parseLocalization.ts index b3a57bdd2a7..09463280704 100644 --- a/packages/ui/src/localization/parseLocalization.ts +++ b/packages/ui/src/localization/parseLocalization.ts @@ -13,25 +13,20 @@ import { defaultResource } from './defaultEnglishResource'; let cache: LocalizationResource | undefined; let prev: LocalizationInput | undefined; -const parseLocalizationResource = ( - userDefined: LocalizationInput, - base: LocalizationResource, -): LocalizationResource => { +function parseLocalizationResource(userDefined: LocalizationInput, base: LocalizationResource): LocalizationResource { if (!cache || (!!prev && prev !== userDefined && !deepEqual(userDefined, prev))) { prev = userDefined; - // If no user-defined localization, just return base if (!userDefined || Object.keys(userDefined).length === 0) { cache = base; return cache; } - // Validate no mixing of formats (throws if mixed) - validateLocalizationFormat(userDefined as Record); + const input = userDefined as Record; + validateLocalizationFormat(input); - // Convert flattened to nested if needed - const normalized = isFlattenedObject(userDefined as Record) - ? unflattenObject(userDefined as Record) + const normalized = isFlattenedObject(input) + ? unflattenObject(input) : (userDefined as LocalizationResource); const res = {} as LocalizationResource; @@ -41,7 +36,7 @@ const parseLocalizationResource = ( return cache; } return cache; -}; +} export const useParsedLocalizationResource = () => { const { localization } = useOptions(); From bb0a8509b26107d2cf315e839e619a277b163fc8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Jan 2026 09:24:25 -0500 Subject: [PATCH 3/3] handle edge cases --- .../utils/__tests__/unflattenObject.spec.ts | 88 +++++++++++++++++++ packages/shared/src/utils/unflattenObject.ts | 62 ++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/utils/__tests__/unflattenObject.spec.ts b/packages/shared/src/utils/__tests__/unflattenObject.spec.ts index ee89ed35fd3..5ce6b015d68 100644 --- a/packages/shared/src/utils/__tests__/unflattenObject.spec.ts +++ b/packages/shared/src/utils/__tests__/unflattenObject.spec.ts @@ -133,4 +133,92 @@ describe('unflattenObject', () => { }, }); }); + + describe('conflict detection', () => { + test('throws error when primitive value blocks nested path', () => { + const flat = { a: 'x', 'a.b': 'y' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a'/); + expect(() => unflattenObject(flat)).toThrow(/cannot set 'a\.b'/); + }); + + test('throws error when nested path blocks primitive value', () => { + const flat = { 'a.b': 'x', a: 'y' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a'/); + }); + + test('throws error when nested structure blocks primitive assignment', () => { + const flat = { 'a.b.c': 'x', 'a.b': 'y' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/); + }); + + test('throws error when primitive value blocks nested structure', () => { + const flat = { 'a.b': 'x', 'a.b.c': 'y' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/); + }); + + test('throws error for deep nested conflict', () => { + const flat = { 'a.b.c.d': 'x', 'a.b': 'y' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/); + }); + + test('successfully merges keys at same level (no conflict)', () => { + const flat = { 'a.b': 'x', 'a.c': 'y' }; + expect(unflattenObject(flat)).toEqual({ a: { b: 'x', c: 'y' } }); + }); + + test('throws error when null value blocks nested path', () => { + const flat = { a: null, 'a.b': 'x' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a'/); + expect(() => unflattenObject(flat)).toThrow(/null/); + }); + + test('throws error when array value blocks nested path', () => { + const flat = { a: [1, 2], 'a.b': 'x' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/); + expect(() => unflattenObject(flat)).toThrow(/path 'a'/); + expect(() => unflattenObject(flat)).toThrow(/array/); + }); + }); + + describe('edge cases', () => { + test('throws error for empty string segments (consecutive dots)', () => { + const flat = { 'a..b': 'value' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/empty segments/); + expect(() => unflattenObject(flat)).toThrow(/key 'a\.\.b'/); + }); + + test('throws error for leading dot', () => { + const flat = { '.a': 'value' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/empty segments/); + expect(() => unflattenObject(flat)).toThrow(/key '\.a'/); + }); + + test('throws error for trailing dot', () => { + const flat = { 'a.': 'value' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/empty segments/); + expect(() => unflattenObject(flat)).toThrow(/key 'a\.'/); + }); + + test('throws error for empty key', () => { + const flat = { '': 'value' }; + expect(() => unflattenObject(flat)).toThrow(); + expect(() => unflattenObject(flat)).toThrow(/empty segments/); + }); + }); }); diff --git a/packages/shared/src/utils/unflattenObject.ts b/packages/shared/src/utils/unflattenObject.ts index 2a18ecddd64..2ca65e92824 100644 --- a/packages/shared/src/utils/unflattenObject.ts +++ b/packages/shared/src/utils/unflattenObject.ts @@ -29,6 +29,26 @@ export function validateLocalizationFormat(obj: Record): void { } } +/** + * Checks if a value is a non-null, non-array object that can be used for nesting. + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Returns a human-readable type description for error messages. + */ +function getTypeDescription(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return typeof value; +} + /** * Converts a flattened object to nested format. * { "a.b.c": "value" } => { a: { b: { c: "value" } } } @@ -38,15 +58,51 @@ export function unflattenObject>(flat: Record< for (const [key, value] of Object.entries(flat)) { const parts = key.split('.'); + if (parts.some(part => part === '')) { + throw new Error( + `Clerk: Localization key '${key}' contains empty segments (consecutive dots, leading/trailing dots are not allowed)`, + ); + } + let current = result; + const pathSegments: string[] = []; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; - current[part] = current[part] || {}; - current = current[part] as Record; + pathSegments.push(part); + + if (part in current) { + const existing = current[part]; + if (!isObject(existing)) { + const conflictingPath = pathSegments.join('.'); + const typeDesc = getTypeDescription(existing); + throw new Error( + `Clerk: Localization key conflict at path '${conflictingPath}': cannot set '${key}' because '${conflictingPath}' already exists as a ${typeDesc}`, + ); + } + current = existing; + } else { + current[part] = {}; + current = current[part] as Record; + } + } + + const finalKey = parts[parts.length - 1]; + if (finalKey in current) { + const existing = current[finalKey]; + const existingIsObject = isObject(existing); + const newIsObject = isObject(value); + + if (existingIsObject !== newIsObject) { + const finalPath = parts.join('.'); + const typeDesc = existingIsObject ? 'nested object' : getTypeDescription(existing); + throw new Error( + `Clerk: Localization key conflict at path '${finalPath}': cannot set '${key}' because '${finalPath}' already exists as a ${typeDesc}`, + ); + } } - current[parts[parts.length - 1]] = value; + current[finalKey] = value; } return result as T;