I am trying to write a set version of ts-optchain
. Where the functionality would attempt to return a copy of the root object with the spliced in change. Such that the original is not changed or modified in any way. Yet, for areas of the object that have not not been modified, they copied over into the shallow copy operation as references (via Object.assign(...)
).
My test that I am trying to validate for is as follows:
const example = { a: { b: { c: { d: 5 } } } };
const out = osc(example).a.b.c.d(6);
expect(out).to.be.deep.eq({ a: { b: { c: { d: 6 } } } });
... where osc
(optional set chain) is the function I made to mimmic opt-chain
's oc
function.
I am expecting the result be somewhat similar to Object.assign({}, example, {a: Object.assign({}, example.a, {b: Object.assign({}, example.a.b, {c: Object.assign({}, example.a.b.c, {d: 6})})})});
The approach above is a pain to write, read and maintain. Hence the reasoning to make this function.
My attempt to make this is as follows:
// ----- Types -----
// Generic type "R" -> The returned root object type when setting a value
// Generic type "T" -> The type for the proxy object
interface TSOSCDataSetter<R, T> {
(value: Readonly<T>): Readonly<R>;
}
type TSOSCObjectWrapper<R, T> = { [K in keyof T]-?: TSOSCType<R, T[K]> };
interface TSOSCArrayWrapper<R, T> {
length: TSOSCType<R, number>;
[K: number]: TSOSCType<R, T>;
}
interface TSOSCAny<R> extends TSOSCDataSetter<R, any> {
[K: string]: TSOSCAny<R>; // Enable deep traversal of arbitrary props
}
type TSOSCDataWrapper<R, T> =
0 extends (1 & T) // Is T any? (https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360)
? TSOSCAny<R>
: T extends any[] // Is T array-like?
? TSOSCArrayWrapper<R, T[number]>
: T extends object // Is T object-like?
? TSOSCObjectWrapper<R, T>
: TSOSCDataSetter<R, T>;
export type TSOSCType<R, T> = TSOSCDataSetter<R, T> & TSOSCDataWrapper<R, T>;
// ----- Helper functions -----
function setter<K extends keyof V, V>(original: () => (Readonly<V> | undefined), key: K, value: Readonly<V[K]>): Readonly<V> {
// Shallow copies this layer with the spliced in value specified. Works with both dictionaries and lists.
return Object.assign(typeof key === "string" ? {} : [], original(), { [key]: value });
}
function getter<K extends keyof V, V>(object: Readonly<V> | undefined, key: K): Readonly<V[K]> | undefined {
// Assists in optionally fetching down a continuous recursive chain of index-able objects (dictionaries & lists)
return object === undefined ? object : object[key];
}
// ----- Internal recursive optional set chain function -----
function _osc<R, K extends keyof V, V>(root: Readonly<R> | undefined, get_chain: () => (Readonly<V> | undefined), set_chain: (v: Readonly<V>) => Readonly<R>): TSOSCType<R, V> {
// `root` is passed in as an argument and never used. This is just to maintain the typing for <R>.
// `get_chain` is a constructed recursive function that will return what the value of this object is at this node.
// `set_chain` is a constructed recursive function that will assist in building and splicing in the specified value.
return new Proxy(
{} as TSOSCType<R, V>, // Blank object. I don't use `target`.
{
get: function (target, key: K): TSOSCType<R, V[K]> {
const new_get_chain = (): (Readonly<V[K]> | undefined) => getter(get_chain(), key);
const new_set_chain = (v: Readonly<V[K]>): Readonly<R> => set_chain(setter(get_chain, key, v));
return _osc(root, new_get_chain, new_set_chain);
},
apply: function (target, thisArg, args: [Readonly<V>]): Readonly<R> {
return set_chain(args[0]);
}
}
);
}
// ----- Exposed optional set chain function -----
export function osc<R, K extends keyof R>(root: Readonly<R> | undefined): TSOSCType<R, R> {
const set_chain = (value: Readonly<R>): Readonly<R> => value;
return _osc(root, () => root, set_chain);
}
Unfortunately, I get the error: osc(...).a.b.c.d is not a function
. This is where my confusion begins. Returning from the osc
(and _osc
) functions is type TSOSCType
, which extends the interface TSOSCDataSetter
. The interface TSOSCDataSetter
specifies that the object inheriting the interface is, itself, callable:
interface TSOSCDataSetter<R, T> {
(value: Readonly<T>): Readonly<R>;
}
Retuned from both osc
and _osc
is a Proxy
for the type TSOSCType
(much like ts-optchain
does). This Proxy object assists in building the chain and type completing the object chain. But more importantly for this question, implements the apply
method:
apply: function (target, thisArg, args: [Readonly<V>]): Readonly<R> {
return set_chain(args[0]);
}
So why is the type TSOSCType
not callable?
In TypeScript we can express its type as: ( a : number , b : number ) => number. This is TypeScript's syntax for a function's type, or call signature (also called a type signature). You'll notice it looks remarkably similar to an arrow function—this is intentional!
The reason this happens is because only Proxies around functions may be called (and set the apply
trap). Your cast of {} as TSOSCType<R, V>
masks the fact that what you're doing is impossible in runtime, and instructs TypeScript to trust you (wrongly).
Changing that statement to function(){} as unknown as TSOSCType<R, V>
makes it work as expected.
See this in the Playground.
As a general rule of thumb, whenever you get a runtime TypeError when using TypeScript, it means that TypeScript trusted you and you betrayed it. That almost always means a cast. When you get such errors, your casts should be the immediate suspects.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With