Function decode_json()
takes JSON and decode all keys from any object found.
Example: a
key found in JSON is latitude
(decoded). Why? a
has index of 0 from alphabet
variable and latitude
has index of 0 too from keys
variable.
Issue
Line 4
TS2322: Type 'any[]' is not assignable to type 'T1'.
'T1' could be instantiated with an arbitrary type which could be unrelated to 'any[]'.
Code
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
export default function decode_json<T1>(json: T1): T1 {
if (Array.isArray(json)) return json.map(decode_json);
if (typeof json === 'object')
return Object.entries(json).reduce<{ [key: string]: T1 }>((l, r) => {
if (Array.isArray(r[1])) {
l[decodeKey(r[0])] = r[1].map(decode_json);
return l;
}
if (typeof r[1] === 'object') {
l[decodeKey(r[0])] = decode_json(r[1]);
return l;
}
l[decodeKey(r[0])] = r[1];
return l;
}, {});
return json;
}
function decodeKey(key: string): string {
if (alphabet.indexOf(key) !== -1) return keys[alphabet.indexOf(key)];
throw new Error(`The key "${key}" does not exist.`);
}
const keys: string[] = [
'latitude',
'longitude',
];
minimal example
function will add "blabla" as suffix to every single key in object or array of objects
function addBlabla<T1>(json: T1): T1 {
if (Array.isArray(json)) return json.map(addBlabla);
if (typeof json === "object")
return Object.entries(json).reduce<{ [key: string]: T1 }>((l, r) => {
l[r[0] "blabla"] = r[1];
return l;
}, {});
return json;
}
CodePudding user response:
NB: I'm only going to work on the minimal example; it can be extended to your original version if needed, but I'll leave that to you.
The big issue here is that your function's output is definitely not the same type as its input. If you call addBlabla(json)
where json
is of type {a: number}
, the return type will be {ablabla: number}
. Those are not the same, and so if the json
function parameter is of type T
, you cannot claim that the return type is also T
.
What is the return type if the input is of type T
, which we shall call Blabla<T>
? Well, we can use key remapping and template literal types to express what happens. Here's one way to do it:
type Blabla<T> = T extends readonly any[] ? { [K in keyof T]: Blabla<T[K]> } :
T extends object ? { [K in keyof T as `${Exclude<K, symbol>}blabla`]: T[K] } : T;
This is a conditional type that checks if T
is arraylike (readonly any[]
is more permissive than any[]
) and, if so, maps it to a new array where the elements each have Blabla<>
applied to them.
If T
is a non-arraylike object, we re-map each key from K
to `${Exclude<K, symbol>}blabla`
. This uses a template literal type to append "blabla"
to the end of the key. Note that keyof T
can include symbol
-typed keys but template literal types cannot append to symbols, so we use the Exclude<T, U>
utility type to suppress any symbol
-valued keys. (In your implementation, Object.entries()
will exclude symbol
keys anyway, so this is the right thing to do.)
Finally, if T
is not an array or an object, the type evaluates to just T
.
So the type of addBlaBla
will look like:
function addBlabla<T>(json: T): Blabla<T> { /* impl */ }
At this point you will start running into the same errors as before if you just use your implementation as-is. Inside the implementation, the type T
is an unspecified generic type parameter. And the compiler is unable to do very sophisticated analysis on such types.
When you write
if (Array.isArray(json)) return json.map(addBlabla);
the compiler can use control flow analysis to narrow json
from type T
to something like T & any[]
, and it lets you call map()
. But what it does not do is narrow the type parameter T
itself. And so it has no idea if json.map(addBlabla)
will result in Blabla<T>
.
The type Blabla<T>
is essentially opaque to the compiler inside the implementation of addBlabla()
. This is either a current or permanent design limitation in Typescript. The canonical issue in GitHub about this is probably microsoft/TypeScript#33912, but there are other related issues. For now, we just can't expect the compiler to figure out that any particular value will be assignable to Blabla<T>
for unspecified generic T
.
That means we need to work around it. In cases where you know what type a value is but the compiler doesn't, you can use type assertions to tell the compiler what it doesn't know:
function addBlabla<T>(json: T): Blabla<T> {
if (Array.isArray(json)) return json.map(addBlabla) as any as Blabla<T>;
if (typeof json === "object")
return Object.entries(json).reduce<{ [key: string]: T }>((l, r) => {
l[r[0] "blabla"] = r[1];
return l;
}, {}) as Blabla<T>;
return json as Blabla<T>;
}
This now compiles without error. You need to be careful not to lie to the compiler, so you should triple check that the assertion is accurate before doing so. For example, the following compiles with no error, even though the implementation is wrong:
function addBlablaBad<T>(json: T): Blabla<T> {
return json as Blabla<T>; // this is a lie, but no error
}
It's a bit annoying to have to use as Blabla<T>
three times in the code, so we can do something nearly equivalent to type assertions: a single-call-signature overloaded function:
function addBlabla<T>(json: T): Blabla<T>;
function addBlabla<T>(json: T) {
if (Array.isArray(json)) return json.map(addBlabla);
if (typeof json === "object")
return Object.entries(json).reduce<{ [key: string]: T }>((l, r) => {
l[r[0] "blabla"] = r[1];
return l;
}, {});
return json;
}
Overloaded function implementations are checked more loosely than regular functions, so the compiler is happy with the above. It's just as unsafe as type assertions, so you should really check the implementation just as much as before. For example, no error here:
function addBlablaBad<T>(json: T): Blabla<T>;
function addBlablaBad<T>(json: T) {
return json; // this is a lie, but no error
}
So be careful!