Skip to content

Add RenameKey and RenameKeys types#1435

Open
materwelonDhruv wants to merge 14 commits into
sindresorhus:mainfrom
materwelonDhruv:add-rename-key
Open

Add RenameKey and RenameKeys types#1435
materwelonDhruv wants to merge 14 commits into
sindresorhus:mainfrom
materwelonDhruv:add-rename-key

Conversation

@materwelonDhruv

Copy link
Copy Markdown

Renames one or more keys in an object type while preserving each value type and the ? / readonly modifiers. Distributes over union object types.

RenameKey is the single-key form; RenameKeys takes a map of old-to-new names for batch renames. Useful for deriving a JSON-shape from an internal type (camelCase to snake_case), relabeling a discriminator across a tagged union, or any spot where Merge / OverrideProperties would let you change a value type but not the key itself, as examples.

I use this in my framework, seedcord's, docs site, where I have a WithDeprecationStatus mixin that attaches a deprecationStatus field to entity types (classes, methods, properties). When rendering a child member of a deprecated parent, the section component needs to know the parent's status without claiming the child itself is deprecated, so I derive WithParentDeprecationStatus = RenameKey<WithDeprecationStatus, 'deprecationStatus', 'parentDeprecationStatus'>. Because of this, it's just one line and has no risk of the two types drifting. And renaming the source key automatically flags every downstream rename if I ever touch the mixin.

Some other examples below:

import type { RenameKey, RenameKeys } from 'type-fest';

type User = {
    readonly id: string;
    firstName: string;
    createdAt: Date;
};

type SingleRenamed = RenameKey<User, 'createdAt', 'created_at'>;
//=> {readonly id: string; firstName: string; created_at: Date}

type BatchRenamed = RenameKeys<User, { firstName: 'first_name'; createdAt: 'created_at' }>;
//=> {readonly id: string; first_name: string; created_at: Date}

type Event = { kind: 'click'; target: string } | { kind: 'submit'; target: HTMLFormElement };

type Renamed = RenameKey<Event, 'target', 'element'>;
//=> | {kind: 'click'; element: string}
//   | {kind: 'submit'; element: HTMLFormElement}

@sindresorhus

Copy link
Copy Markdown
Owner

I think this is close, but I would not merge it with collision support as-is.

The collision behavior is unsafe. The tests currently say that renaming a to an existing b should collapse the value types into b: number | string, but modifiers collapse too. For example:

type Result = RenameKey<{a?: number; b: string}, 'a', 'b'>;

This becomes {b?: number | string}, so this is accepted:

const value: Result = {};

That means renaming an optional key over a required key accidentally makes the required key optional. I think the simpler and better contract is to not support target-key collisions, except maybe renaming a key to itself. Supporting collisions correctly would need a lot more subtle modifier rules, and I don't think the use-case is worth it.

There is also a small issue with RenameKeys: optional entries in the rename map are accepted but silently ignored.

type Result = RenameKeys<{a: number; b: string}, {a?: 'alpha'}>;

This ends up as {a: number; b: string}, not {alpha: number; b: string}. Either the map entries should be required, or optional map entries should be rejected. Silently doing nothing is suprising.

Minor: there is a new unused eslint-disable-next-line no-lone-blocks warning in the tests.

@materwelonDhruv

Copy link
Copy Markdown
Author

Thanks! That makes sense. I reworked it to drop collision support entirely and expanded tests with positive + negative coverage for a bunch of edge cases. The comments explain them.

Also, a consequence of this is that since the check is KeysOfUnion-based, a target present in another union member (or implied by a string index signature) is also rejected. I think that is good, actually.

I was also wondering about a RenameKeyDeep type which would be modeled on SetRequiredDeep + Paths. But that's for a later PR if you think it would be a good addition.

Re the lint: the lone blocks are intentional as each case block-scopes a reused local Source/Bad alias to keep the cases uniform. I replaced the per-line directives with a single paired file-level eslint-disable for no-lone-blocks, with a justification comment.

@som-sm som-sm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test cases fail currently:

type T1 = RenameKeys<{a: 1; b: 2}, {a: 'x'} | {b: 'y'}>;
//=> {a: 1; b: 2}

type T2 = RenameKeys<{a: 1; b: 2}, {a: 'b' | 'c'}>; // Doesn't error, but should
//=> {b: 1 | 2; c: 1}

type T3 = RenameKeys<{[x: string]: number; a: 1}, {a: 'b'}>; // Errors, but shouldn't

Comment thread test-d/rename-keys.ts Outdated
Comment thread source/rename-keys.d.ts Outdated
Comment thread source/rename-key.d.ts Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need RenameKey when we already have RenameKeys. If only a single key needs renaming, the latter can still be used. And moreover, providing two ways of doing the same thing with different APIs can cause confusion, so it's better to just have one.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Would you rather I remove RenameKey then?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you can remove RenameKey.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a side note, I had a case for keeping RenameKey. Will write back here soon.

@materwelonDhruv materwelonDhruv May 31, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason I'd keep RenameKey is ergonomics for the common case. RenameKey<User, 'createdAt', 'created_at'> takes three positional arguments: source, from-key, to-key. RenameKeys<User, {createdAt: 'created_at'}> wraps the same rename in an object literal. For the single-key case, the positional form maps to how you'd phrase the operation in English ("rename createdAt to created_at").

This pair-of-surfaces pattern already exists in the library. And<A, B> is a one-line wrapper over AndAll<[A, B]> (source/and.d.ts:80), and Or<A, B> is the same over OrAll<[A, B]>. Both pairs ship together in the README and cross-reference each other with @see. The binary positional forms exist for the two-argument case; the variadic forms exist for the general case.

RenameKey can follow the same shape (this might change, I'm still testing):

export type RenameKey<BaseType, FromKey extends KeysOfUnion<BaseType>, ToKey extends PropertyKey> = 
	RenameKeys<BaseType, {[Key in FromKey]: ToKey}>;

So no duplicated logic as well, and a fix to RenameKeys applies to RenameKey automatically.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think it makes sense to keep RenameKey. RenameKey/RenameKeys is not the same as Or/OrAll. There we cannot remove Or and just have OrAll because Or is a pretty standard type, so we need to keep it. But that's not the case here, RenameKey doesn't need to be present.

And, in terms of ergonomics, I think RenameKeys<User, {createdAt: 'created_at'}> is just about as good as RenameKey<User, 'createdAt', 'created_at'>.

@som-sm

som-sm commented May 31, 2026

Copy link
Copy Markdown
Collaborator

A more nuanced issue:

type T = RenameKeys<{a: number; b: string}, {a: string}>;
//=> {[x: string]: number; b: string}

The output {[x: string]: number; b: string} is broken, refer:

image

This one would probably be quite hard to tackle properly.

@materwelonDhruv

materwelonDhruv commented May 31, 2026

Copy link
Copy Markdown
Author

I'll work on all this feedback and set some stuff up in a TypeScript playground link so we can iterate more quickly before I push.

Ended up testing things in vscode itself...

type now checks in the body instead of the params so I can check for various conditions that have to be met before a rename is safe to do
@materwelonDhruv

materwelonDhruv commented May 31, 2026

Copy link
Copy Markdown
Author

I pushed a redesign that addresses everything in the thread.

V imp. The constraint is now structural-only (Record<PropertyKey, PropertyKey>). All semantic validation moved into the body. This is the only constraint shape that defers cleanly under generic instantiation, which fixes the generic-test case @som-sm wrote above. The tradeoff is that a mistake when you use the type returns never from the body instead of erroring at the call site and the error surfaces when the result is consumed (assignment, indexing) rather than at the declaration. Probably better too I think per type-fest's role as a composable utility.

  • sindre's collision concern (modifier collapse via optional → required): dropped collision support from the constraint. A body-side check returns never when any target in the rename map matches a kept literal key of the source (kept = literal keys minus the ones being renamed away). Swapping keys still works because both are simultaneously renamed away.

  • sindre's optional-entry slip-through ({a?: 'b'} silently ignored): now returns never via HasOptionalKeys<RenameMap> in the body.

  • sindre's unused no-lone-blocks lint: tests are rewritten in the inline expectType<X>({} as Y) style like the rest of the lib now.

  • som-sm's T1 ({a: 'x'} | {b: 'y'} doesn't distribute): added explicit distribution via RenameMap extends RenameMap ? body : never. Each member of the rename-map union runs through the body independently.

  • som-sm's T2 ({a: 'b' | 'c'} doesn't error) and the related {a: string} case: rejected via a body-side _AllTargetsAreSingleLiterals check that requires each target to be a single literal PropertyKey (no unions, no primitives like string).

  • som-sm's T3 (index signature false-positive collision): kept-key set now excludes index signatures via OmitIndexSignature. A literal target onto a string-indexed object is no longer flagged; the index signature doesn't preclude adding a named property. RenameKey<{[x: string]: number; a: 1}, 'a', 'b'> now produces {[x: string]: number; b: 1} instead of erroring.

  • som-sm's generic instantiation case ({[P in K]: \new_${P}`}` errors under generic T, K): works now too.

  • som-sm on dropping RenameKey: I'd like to keep it. It's now a one-line wrapper:

export type RenameKey<BaseType, FromKey extends KeysOfUnion<BaseType>, ToKey extends PropertyKey> = RenameKeys<
    BaseType,
    { [Key in FromKey]: ToKey }
>;

The pair-of-surfaces pattern already exists in type-fest: And<A, B> wraps AndAll<[A, B]> (source/and.d.ts:80), and Or<A, B> wraps OrAll<[A, B]>. Both pairs ship together and cross-reference each other. RenameKey over RenameKeys follows the same shape. There's no duplicated logic since the wrapper delegates fully, so a fix to RenameKeys applies to RenameKey automatically. The positional form (RenameKey<User, 'createdAt', 'created_at'>) also reads more directly at the call site for the single-key case than the object-literal form. I also mentioned this above in the comments.

Two cases the constraint can't catch and the type doesn't reject cleanly so I short-circuit directly:

  • IsAny<BaseType> returns never: matches the existing test's "can't prove safe" policy.
  • IsNever<BaseType> returns never: needed because KeysOfUnion<never> evaluates to string | number | symbol (not never), so the natural constraint doesn't reject never BaseType.

@materwelonDhruv

materwelonDhruv commented May 31, 2026

Copy link
Copy Markdown
Author

If I were to open a PR that sets up husky to run tests before push and lint-staged before commit, would that be merged? I feel that should definitely be there in a library like this.

@som-sm

som-sm commented May 31, 2026

Copy link
Copy Markdown
Collaborator

If I were to open a PR that sets up husky to run tests before push and lint-staged before commit, would that be merged? I feel that should definitely be there in a library like this.

No, I don’t think we want to add that. These tools add unnecessary friction, and blocking commits tends to make contributing more annoying than helpful. Contributors should be able to run checks locally when it makes sense, with CI checks acting as the final gate.

@sindresorhus

@sindresorhus

Copy link
Copy Markdown
Owner

Yeah, I don't want that. I had those in the past in other projects and caused more problems than they saved.

@som-sm

som-sm commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Few thoughts:

  • I think returning input object as-is is better than returning never in case of collisions.
  • And, maybe it's better to just ignore keys that are not present in the input object. This pattern is kinda common, like Omit<{a: 1; b: 2}, 'a' | 'c'> returns back {b: 2}, ignoring 'c'.

IMO, returning never doesn't feel great!

@materwelonDhruv

Copy link
Copy Markdown
Author

Hmmmmm. I do agree that never doesn't feel great as the result of a type. But, I think missing keys and collisions are different cases as well.

For missing keys ({nme: 'fullName'} on {name, id}) Omit style ignore is fine, ye. Like what I was doing before. Error when using the type itself via the type params.

For collisions tho ({a: 'b'} when b is kept), it should return something that the user HAS to pay attention to. The map said "overwrite b with the type of a under the name b", and returning the input as-is silently doesn't do that. That's a false-success I feel. Same goes for duplicate targets ({a: 'x'; b: 'x'}) where a pass through would have to pick one arbitrarily?

Maybe we go lenient on missing keys like Omit, and keep the never, so strict, on collisions, duplicate targets, basically anything that is "no this should not be allowed" vs "there isn't really a problem with not doing anything about this"

@materwelonDhruv materwelonDhruv requested a review from som-sm June 19, 2026 11:11
@som-sm

som-sm commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

@materwelonDhruv How hard do you think it would be to handle collisions?

I believe these are the only cases we need to consider when multiple properties p1, p2, …, pn are all being renamed to the same key new_p:

  1. Optionality is uniform (all required or all optional)

    The value of new_p is a union of the values of p1, p2, …, pn, and the optional modifier is preserved.

    // All required → required
    type T = RenameKeys<{p1: string; p2: number; p3: bigint}, {p1: 'new_p'; p2: 'new_p'; p3: 'new_p'}>;
    //=> {new_p: string | number | bigint}
    // All optional → optional
    type T = RenameKeys<{p1?: string; p2?: number; p3?: bigint}, {p1: 'new_p'; p2: 'new_p'; p3: 'new_p'}>;
    //=> {new_p?: string | number | bigint}
  2. Mixed optionality (at least one required, at least one optional)

    If at least one property is required, then new_p should be required. And it's value is a union of the values of p1, p2, …, pn.

    Note: In this case, the exactOptionalPropertyTypes compiler flag affects the result. When disabled, new_p's value should include an additional undefined, refer:

    // exactOptionalPropertyTypes: true
    type T = RenameKeys<{p1: string; p2?: number; p3: bigint}, {p1: 'new_p'; p2: 'new_p'; p3: 'new_p'}>;
    //=> {new_p: string | number | bigint}
    // exactOptionalPropertyTypes: false
    type T = RenameKeys<{p1: string; p2?: number; p3: bigint}, {p1: 'new_p'; p2: 'new_p'; p3: 'new_p'}>;
    //=> {new_p: string | number | bigint | undefined}
  3. Mixed readonly modifiers

    If at least one source property is readonly, we could go either way on the result. I'd lean towards making new_p readonly; it's the safer, more conservative choice.

    type T = RenameKeys<{p1: string; readonly p2: number; p3: bigint}, {p1: 'new_p'; p2: 'new_p'; p3: 'new_p'}>;
    //=> {readonly new_p: string | number | bigint}

@materwelonDhruv

Copy link
Copy Markdown
Author

@som-sm just to make sure I'm reading this right. You want these to be valid and produce a merged result, not return never? The current impl rejects duplicate targets like {p1: 'new_p'; p2: 'new_p'} outright, so I want to confirm you're asking me to drop that check and produce the merge instead. I'll try to figure that out then.

Do you think that has valid use cases? This is basically imitating a merge, right? Also, are you sure this being allowed is a good idea? We should guard against this kind of rename right? Why would someone want to do that? Why should someone want to rename keys this way?

@som-sm

som-sm commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

so I want to confirm you're asking me to drop that check and produce the merge instead.

Yup, correct!

This is basically imitating a merge, right?

Yeah kinda, just that merge is limited to only two items, this can happen with any no. of items.


There could be several valid use cases for merging. Here are some AI-suggested ones that look pretty reasonable:

type SearchInput = {
  textQuery: string;
  voiceQuery: Blob;
  imageQuery: File;
};

type Normalized = RenameKeys<SearchInput, {textQuery: 'query'; voiceQuery: 'query'; imageQuery: 'query'}>;
//=> {query: string | Blob | File}

type LegacyUser = {
  oldId?: string;
  numericId: number; 
  name: string;
};

type Migrated = RenameKeys<LegacyUser, {oldId: 'id'; numericId: 'id'}>;
//=> {id: string | number; name: string}

@materwelonDhruv

Copy link
Copy Markdown
Author

oki imma try.

@materwelonDhruv

Copy link
Copy Markdown
Author

We just came full circle lmao. Because I think @sindresorhus explicitly asked to reject such collisions where we end up in a union 😅

Some tests that currently reject this behavior will change now.

@som-sm

som-sm commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

The collision behavior is unsafe. The tests currently say that renaming a to an existing b should collapse the value types into b: number | string, but modifiers collapse too. For example:

type Result = RenameKey<{a?: number; b: string}, 'a', 'b'>;

This becomes {b?: number | string}, so this is accepted:

const value: Result = {};

That means renaming an optional key over a required key accidentally makes the required key optional. I think the simpler and better contract is to not support target-key collisions, except maybe renaming a key to itself. Supporting collisions correctly would need a lot more subtle modifier rules, and I don't think the use-case is worth it.

This was an issue because the merged result was incorrect; it would have been fine if the result were {b: number | string}.

And also, there aren't many cases here, so I guess it's worth solving it!


Some tests that currently reject this behavior will change now.

That's fine!

@materwelonDhruv

materwelonDhruv commented Jun 22, 2026

Copy link
Copy Markdown
Author

okay that makes sense. I'm nearly done with it. testing with the cases you gave and some others of my own. and also simplifying the implementation a bit.

@som-sm som-sm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently fails:

type T = RenameKeys<{a: string}, {a: 'b' | 'c'}>;
//=> Current: never
//=> Expected: {b: string; c: string}

Comment thread test-d/rename-keys.ts Outdated
Comment thread source/rename-key.d.ts Outdated
Comment thread test-d/rename-keys.ts Outdated
Comment thread source/rename-keys.d.ts Outdated
@materwelonDhruv

Copy link
Copy Markdown
Author

type T = RenameKeys<{a: string}, {a: 'b' | 'c'}>;

still working on this

@materwelonDhruv materwelonDhruv requested a review from som-sm June 22, 2026 19:52

@som-sm som-sm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation fails in this case:

// @exactOptionalPropertyTypes: true

type T1 = RenameKeys<{a?: number; b: string}, {a: 'x'; b: 'x'}>;
//=> Current: {x?: string | number}
//=> Expected: {x: string | number}

The current implementation doesn't explicitly handle optional properties. Instead, it relies on TS's default behaviour, which isn't sufficient in this case because by default, if multiple keys are being aliased to the same key, TS picks up the modifier of the first key:

type Test<BaseType extends object> =
	{
		[Key in keyof BaseType as 'z']: Required<BaseType>[Key];
	};

type T1 = Test<{a: number; b: string; c?: bigint}>;
//=> { z: string | number | bigint; }

type T2 = Test<{a: number; b?: string; c: bigint}>;
//=> { z: string | number | bigint; }

type T3 = Test<{a?: number; b: string; c: bigint}>;
//=> { z?: string | number | bigint; }

@materwelonDhruv

Copy link
Copy Markdown
Author

I did a fix for this for readonly because it was only picking up the first key's modifier by default. hmmmm. need to do something similar for optional as well. I might have to strip optional while renaming and reapply or something. i'll try some things out and add a few more tests

@materwelonDhruv

materwelonDhruv commented Jun 23, 2026

Copy link
Copy Markdown
Author

found another bug. gonna fix it

RenameKeys<{[x: string]: number; a?: 1}, {a: 'b'}>
// Expected. {[x: string]: number; b?: 1}
// Actual bugged: {[x: string]: number; b: 1}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants