I'm trying to make a primitive JSON storage in Typescript. I've stumbled upon this article on typing JSON serialization. However I'm running into issues when trying to utilize generics.
type JSONPrimitive = string | number | boolean | null | undefined;
type JSONValue = JSONPrimitive | JSONValue[] | {
[key: string]: JSONValue;
};
type JSONCompatible<T> = unknown extends T ? never : {
[P in keyof T]: T[P] extends JSONValue ? T[P] :
T[P] extends NotAssignableToJson ? never :
JSONCompatible<T[P]>;
};
type NotAssignableToJson = bigint | symbol | Function;
declare class MyStorageManager {
private filePath;
private stores;
constructor(filePath: string);
getStore<K extends string, T extends Record<string, JSONCompatible<T>>>(key: K, defaultValue: T): Store;
save(): void;
load(): void;
}
declare class Store {
data: Record<string, JSONValue>;
private storageManager;
constructor(data: Record<string, JSONValue>, storageManager: MyStorageManager);
get(key?: string, defaultValue?: JSONValue): JSONValue;
set<T>(key: string, value: JSONCompatible<T>): void;
save(): void;
}
const storageManager = new MyStorageManager('./data.json');
const defaultData = {
test: null,
astring: 'string'
}
const store1 = storageManager.getStore('store1', defaultData);
This throws the following error (on the line where I call getStore
, on the defaultData
argument):
Argument of type '{ test: null; astring: string; }' is not assignable to parameter of type 'Record<string, { test: null; astring: string; }>'.
Property 'test' is incompatible with index signature.
Type 'null' is not assignable to type '{ test: null; astring: string; }'.
I've added the complete code to a TS playground here. I've fiddled around with this a lot but I'm constantly running into different issues. Asking here as a last resort: What would be an efficient solution here?
It looks like JSONCompatible<T>
is meant to act as a generic constraint on T
; that is, T extends JSONCompatible<T>
is a recursive constraint that lets you express things you can't express with specific types. (I assume there is no suitable specific JSONCompatible
type here, so T extends JSONValue
doesn't meet your needs... presumably because of the issue described at microsoft/TypeScript#15300 with index signatures).
So T
is a valid JSON value if and only if T extends JSONCompatible<T>
is true. From looking at the definition of JSONCompatible<T>
, it seems like when T
is a valid JSON value, then JSONCompatible<T>
is effectively the same as T
. That means you can treat JSONCompatible<T>
as a "verified" version of T
.
But for MyStorageManger
's getStore()
method you've got
getStore<K extends string, T extends Record<string, JSONCompatible<T>>>(
key: K, defaultValue: T
): Store;
and unfortunately T extends Record<string, JSONCompatible<T>>
is not saying what you want it to say. Presumably you want to say that T
is an object type where each property is a valid JSON value. But what you're actually saying is that T
is an object where each property is a JSONCompatible<T>
, which is a verified T
. So T
must an object type where each property is also T
. Like a tree structure. But {test: null; astring: string}
is not treelike, so you get an error.
There may be a fully generic way of saying what you're trying to say about the object containing JSON properties. But my inclination would be to avoid that kind of thing and stick with T extends JSONCompatible<T>
, and use T
here for the properties themselves. Like this:
getStore<K extends string, T extends JSONCompatible<T>>(
key: K, defaultValue: Record<string, T>
): Store;
So now defaultValue
is of type Record<string, T>
, and T
has been verified.
When you make that change, it allows the code to compile:
const store1 = storageManager.getStore('store1', defaultData); // okay
here T
is inferred as string | null
because defaultData
's properties are of that type.
This may suffice for your use cases. But beware that such recursive generic types, especially those built from conditional types like T[P] extends JSONValue ? T[P] : ⋯
can be quite difficult for TypeScript to analyze correctly. Generally speaking TypeScript will tend to give up when faced with such types, and so it might complain that your operations are unsafe even when you know they are safe.
I'd guess that the real value in something like JSONCompatible<T>
comes in verifying some specific type T
that is directly present in the TypeScript code, whereas the code that processes a generic T
should be shielded from it. So you might need to make getStore()
look like this:
getStore<K extends string, T extends object & JSONCompatible<T>>(
key: K, defaultValue: T
): Store;
Here we're saying that defaultValue
is of some object type (not a primitive, that's what the object
type says), and that it is also a valid JSONCompatible
type (where "also" is represented by an intersection).
This will probably work just fine for a specific T
like the type of defaultData
. But inside the implementation of getStore()
the compiler might complain. If so, I'd say you should just use type assertions or the like inside the implementation to prevent these warnings, after you convince yourself it's properly implemented. That's because the JSONCompatible<T>
check really only helps the caller of getStore()
know if they're using it right, and it does not really help the implementer of getStore()
.
One easy way to separate the call signature from the implementation is to use an overload:
// call signature
getStore<K extends string, T extends object & JSONCompatible<T>>(
key: K, defaultValue: T
): Store;
// implementation
getStore(key: string, defaultValue: any): Store {
// ⋯ impl here ⋯
}
The call signature is what's visible from outside, whereas the implementation uses a looser typing (with string
and any
) to avoid TypeScript warnings.