It started with a very small problem.
In our React component library, we wanted every component to have a nice displayName.
So we did the obvious thing:
Component.displayName = "Component";
Simple. Clear. Boring.
And then reality arrived.
From time to time, developers renamed components. Maybe Button became IconButton. Maybe UserCard became CustomerCard.
But the displayName string?
That stayed behind.
CustomerCard.displayName = "UserCard";
Technically everything still worked.
But debugging became slightly cursed.
React DevTools showed outdated names, logs became misleading, and the code looked correct enough that nobody noticed immediately.
So I started thinking:
How can we force the name to stay in sync with the actual symbol?
Because the real problem was not displayName.
The real problem was this:
"Component"
A string literal does not participate in refactoring.
Your IDE can rename the function. TypeScript can check the types. But that string just sits there, smiling suspiciously.
So the question became more general:
What are the possible ways to get the name of a symbol in a reliable manner?
First attempt: keyof
The first simple helper looked like this:
export function publicNameOf<T_Type>(key: keyof T_Type): typeof key
{
return key;
}
And it kind of works.
For example:
type User =
{
name: string;
age: number;
};
const name = publicNameOf<User>("name");
At least now TypeScript checks that "name" is actually a key of User.
If I write this:
const name = publicNameOf<User>("firstName");
TypeScript complains.
That is already better than a completely random string.
But it still has a few problems.
First, it only works with direct members.
publicNameOf<User>("profile");
That is fine, but it does not naturally express something nested like:
user.profile.avatar.url
Second, it still requires a string literal.
So if the actual member is renamed, your IDE may not update this:
publicNameOf<User>("oldName");
TypeScript may catch it later, depending on the type. But it is still not the same as pointing at the member using normal language syntax.
Third, keyof is still a type-level list of accessible keys.
That matters especially for classes.
For example:
class UserService
{
public start()
{
}
private stop()
{
}
}
type UserServiceKey = keyof UserService;
UserServiceKey contains "start", but it does not contain "stop".
And that makes sense. From the outside, stop is not part of the public shape of UserService.
But there is an important nuance here.
Inside the class, TypeScript does allow you to access private members. The language already knows whether a particular member access is legal depending on where that access is written.
So sometimes the problem is not:
Give me all public keys of this type.
Sometimes the problem is:
Let me point at this member using the same syntax I would normally use in code.
That is a different goal.
And keyof is not quite that.
What I actually wanted
What I wanted was something closer to C# expression trees.
In C#, you can write something like:
Expression<Func<User, object>> expression = user => user.Profile.Avatar.Url;
And then inspect the expression to understand which member path was used.
I wanted a TypeScript version of that idea.
Something like:
($) => $.Component;
Where $ is some object that has the member I want to point at.
The important part is this:
$.Component
That is not a string.
That is a real property access expression.
So rename refactoring has a chance to follow it.
Of course, TypeScript does not have C# expression trees.
But JavaScript has Proxy.
And sometimes that is enough.
The proxy trick
The idea is surprisingly simple.
We create a fake object.
Every time someone accesses a property on it, we record that property name.
Then we return the same fake object again, so the selector can continue walking deeper.
export type RuntimeSelector<T_Target, T_Member = any> =
(target: T_Target) => T_Member;
export function runtimePathOf
<
T_Target,
T_Member = any
>
(
selector: RuntimeSelector<T_Target, T_Member>
)
: string[]
{
const path: string[] = [];
const pathProxy: string[] = new Proxy
(
path,
{
get: (target, key) =>
{
target.push(key.toString());
return pathProxy;
}
}
);
selector(pathProxy as T_Target);
return path;
}
Now we can do this:
const path = runtimePathOf<User>
(
user => user.profile.avatar.url
);
And the result is:
["profile", "avatar", "url"]
No AST parsing.
No decorators.
No compiler plugin.
Just a runtime proxy pretending to be your object.
Getting only the final name
For the original displayName problem, I did not need the full path.
I only needed the last segment.
So I added a tiny wrapper:
export function runtimeNameOf
<
T_Target,
T_Member = any
>
(
selector: RuntimeSelector<T_Target, T_Member>
)
: string
{
const path = runtimePathOf(selector);
return path[path.length - 1] ?? "";
}
Now this:
const name = runtimeNameOf<User>
(
user => user.profile.avatar.url
);
Returns:
"url"
And this:
const name = runtimeNameOf<$>($ => $.Component);
Returns:
"Component"
What about private members?
This is also where the earlier keyof limitation becomes interesting.
The proxy helper does not bypass TypeScript privacy rules.
It still follows normal TypeScript access rules at the place where the selector is written.
So outside a class, this would still be illegal if stop is private.
But inside the class, where TypeScript allows private access, the selector can point at the private member using normal syntax:
class UserService
{
private stop()
{
}
public getPrivateMethodName()
{
return runtimeNameOf<UserService>(service => service.stop);
}
}
That is something keyof UserService does not give you.
keyof UserService describes the public instance shape.
The selector approach is different: it lets you write a member access expression and lets TypeScript decide whether that access is valid from that location.
So this is not a way to break privacy.
It is a way to follow the language rules instead of asking for a public list of keys.
Back to the component
So now we have the general tool:
runtimeNameOf<$>($ => $.Component);
The next question is:
What should
$actually be?
We need $ to be some type that contains Component.
Where does Component live?
In my case, it is exported from the current module.
And TypeScript can get the type of a module very easily:
typeof import(".")
That gives us the type shape of the current module exports.
So the final version becomes:
Component.displayName = runtimeNameOf<typeof import(".")>
(
$ => $.Component
);
Now Component is referenced through a real property access expression.
If the exported component is renamed, the selector can be updated by normal refactoring tools.
Instead of this fragile version:
Component.displayName = "Component";
We now have this:
Component.displayName = runtimeNameOf<typeof import(".")>
(
$ => $.Component
);
The string is no longer manually written.
It is derived from the property access.
That was the whole point.
What this is not
This is not real reflection.
It does not inspect TypeScript types at runtime.
It does not know anything magical about your source code.
It simply runs a function against a proxy and records which properties were accessed.
So this works:
runtimePathOf<User>
(
user => user.profile.avatar.url
);
But this is not the kind of thing you should do:
runtimePathOf<User>
(
user => user.getProfile().avatar.url
);
Because now you are calling a function on a proxy.
Our proxy only handles property access. It does not behave like a real User.
This helper is meant only for simple property selectors.
The bundler question
There is one important bundler caveat.
The TypeScript type argument is erased:
runtimeNameOf<typeof import(".")>
(
$ => $.Component
);
becomes ordinary JavaScript:
runtimeNameOf($ => $.Component);
So the bundler is not protecting this because it understands typeof import(".").
It simply sees a lambda with a property access.
Normal minification may rename the local parameter:
$ => $.Component
into something like:
n => n.Component
That is fine. The proxy still sees the property key "Component".
The dangerous case is property mangling:
n => n.Component
could become:
n => n.a
Then the proxy would record "a" instead of "Component".
Most common bundler setups do not mangle arbitrary property names by default. They usually mangle local variables and function names, not public-looking property accesses.
So this approach is not protected by TypeScript metadata at runtime.
It is mostly safe because normal bundlers do not rewrite property keys like .Component unless property mangling is explicitly enabled.
So the practical rule is:
This technique avoids function-name minification problems. It is safe for normal public exports in typical bundler setups, but you should still check your production bundle if you use aggressive mangling.
Final result
So the final API is very small:
export type RuntimeSelector<T_Target, T_Member = any> =
(target: T_Target) => T_Member;
export function runtimePathOf
<
T_Target,
T_Member = any
>
(
selector: RuntimeSelector<T_Target, T_Member>
)
: string[]
{
const path: string[] = [];
const pathProxy: string[] = new Proxy
(
path,
{
get: (target, key) =>
{
target.push(key.toString());
return pathProxy;
}
}
);
selector(pathProxy as T_Target);
return path;
}
export function runtimeNameOf
<
T_Target,
T_Member = any
>
(
selector: RuntimeSelector<T_Target, T_Member>
)
: string
{
const path = runtimePathOf(selector);
return path[path.length - 1] ?? "";
}
And the usage is:
Component.displayName = runtimeNameOf<typeof import(".")>
(
$ => $.Component
);
A tiny helper.
A tiny proxy.
And one less stale string literal in the codebase.
Where this idea could go next
This tiny helper only records property access, but the general idea can grow into something much bigger.
A good mental model here is Entity Framework Core in C#.
EF Core can take expression trees, inspect what you wrote, and turn that structure into SQL queries. That is not what this helper does, and JavaScript proxies are not the same thing as C# expression trees.
But the direction is similar: developer-friendly syntax on the outside, metadata on the inside.
Once the object passed into the selector is fake, it does not have to behave exactly like the real object.
It can expose an extended API.
For example, a query builder could return special expression objects from property access and attach comparison helpers to them:
query<User>
(
user => user.age.$gt(18)
);
Or it could use symbols to avoid colliding with real domain members:
query<User>
(
user => user.age[Query.greaterThan](18)
);
At that point, user.age is not really a number at runtime. It is an expression node that represents the age field. Calling $gt(18) or [Query.greaterThan](18) can record a comparison and turn it into query metadata.
That is a different API design from runtimeNameOf, but it comes from the same core idea:
Give developers a typed object-shaped surface, run their selector against controlled proxy objects, and convert the interaction into metadata.
So no, this is not full C# expression trees, and it is not EF Core for TypeScript out of the box. JavaScript will not let a proxy directly intercept operators like >, ===, or &&.
But with an extended API, helpers, or symbol-based methods, this pattern could become the foundation for something much more query-like.
Conclusion
This is one of those TypeScript tricks that is not exactly "real reflection", but still solves a very real problem.
We wanted component display names to stay in sync with actual component symbols.
A simple string was too fragile.
keyof helped a little, but still required manually passing a string and only described the public key shape.
A proxy-based selector gave us a nicer middle ground:
$ => $.Component
It is still runtime JavaScript.
It is still small and understandable.
But now the important part is written as a real property access, so TypeScript and refactoring tools can help us.
And honestly, that is good enough for me.
Top comments (0)