Reputation: 3197
{
key1: "{\"key2\": \"123\", \"key3\": \"ABC\"}"
}
In the above object the value of key1 is a JSON string that needs to be parsed.
Is there a way in typescript to type the parsed value of a JSON string like below?
type hypothetical{
key1: jsonString<{
key2: number;
key3: string;
}>;
}
Upvotes: 4
Views: 3556
Reputation: 328262
What you're looking for is some sort of generic type JsonSerialized<T>
, which is a special kind of string
that the compiler knows will produce a value of type T
when parsed with the JSON.parse()
method. That is, it will behave like this:
interface MyType {
a: string,
b: number,
c: boolean
}
function example(jsonString: JsonSerialized<MyType>) {
const obj = JSON.parse(jsonString); // MyType
console.log(obj.a.toUpperCase()) // okay
console.log(obj.b.toFixed(1)) // okay
obj.d // compiler error, MyType has no d property
}
Here the compiler knows that obj
is of MyType
, and thus it will warn you if you try to access an unknown property like d
(as opposed to the "anything-goes" behavior you'd see if obj
were of the any
type, which is what JSON.parse()
normally produces).
Compare this to the "standard" approach:
function example(jsonString: string) {
const obj: MyType = JSON.parse(jsonString); // MyType
console.log(obj.a.toUpperCase()) // okay
console.log(obj.b.toFixed(1)) // okay
obj.d // compiler error, MyType has no d property
}
Here you have to explicitly tell the compiler to treat obj
as a value of type MyType
... that's not really type safe, since the compiler wouldn't complain if you annotated obj
as being some other type NotMyType
.
Now, you're not trying to get the compiler to actually look at a string and figure out what T
to use in JsonSerialized<T>
. That string won't even exist until runtime anyway. In example code we can use a literal string, but that won't be available to the compiler in your actual use case. So we'll have to tell it:
async function getResponse() {
return Promise.resolve({
key1: '{"a":"foo", "b": 123, "c": true}' as JsonSerialized<MyType>
});
}
That as JsonSerialzed<MyType>
is a type assertion, and you'll have to do that with whatever code receives the relevant string
. So we haven't improved the type safety over the normal way of parsing JSON; we've just moved the safety hole to slightly before calling JSON.parse()
instead of slightly after it.
Now we have to actually define JsonSerialized<T>
. Ideally we'd like to just use string
and have JsonSerialized<T>
be a nominal type that the compiler treats as if it were distinct from string
even though it's just a string
at runtime. But the following won't work:
// doesn't work
type JsonSerialized<T> = string;
TypeScript has a structural type system, not a nominal one. The fact that JsonSerialized<MyType>
and JsonSerialized<YourType>
and string
are all three different names doesn't mean they are three different types. The compiler (mostly) only cares about the structure of a type, and all three of those are just string
. This can lead to unpleasant inference problems; see this FAQ entry for more info.
Instead we can use a trick to simulate nominal types, called "branding". See this FAQ entry for more information. It looks like this:
type JsonSerialized<T> = string & {
__json_seralized: T;
}
Here we are giving a fictitious __json_serialized
property of type T
to JsonSerialized<T>
. Now there is a compile-time structural difference between JsonSerialized<MyType>
and JsonSerialized<YourType>
and string
. But we're just pretending; at runtime the strings will not have any such __json_serialized
property. It's a phantom property just to help the compiler keep track of types.
Okay, we're almost done. All that's left is to let the compiler know that JSON.parse(x)
should produce a value of type T
when x
is of type JsonSerialized<T>
. We could locally fork the TypeScript library to modify its JSON
interface, but that would be terrible to maintain. Luckily you can use declaration merging to add a call signature to JSON.parse()
to your local code base without modifying the upstream library. Like this:
interface JSON {
parse<T>(text: JsonSerialized<T>): T;
}
(if your code is in a module then you will need to wrap that with a declare global {}
block to access the global JSON
interface).
All right, let's try it out:
type JsonSerialized<T> = string & {
__json_seralized: T;
}
interface JSON {
parse<T>(text: JsonSerialized<T>): T;
}
interface MyType {
a: string,
b: number,
c: boolean
}
function example(jsonString: JsonSerialized<MyType>) {
const obj = JSON.parse(jsonString); // MyType
console.log(obj.a.toUpperCase()) // okay
console.log(obj.b.toFixed(1)) // okay
obj.d // compiler error, MyType has no d property
}
async function getResponse() {
return Promise.resolve({
key1: '{"a":"foo", "b": 123, "c": true}' as JsonSerialized<MyType>
});
}
async function doSomething() {
const resp = await getResponse();
example(resp.key1);
}
doSomething(); // FOO, 123.0
Hooray, it all works!
But, let's compare this to the standard way of doing this:
interface MyType {
a: string,
b: number,
c: boolean
}
async function getResponse() {
return Promise.resolve({
key1: '{"a":"foo", "b": 123, "c": true}'
});
}
function example(jsonString: string) {
const obj: MyType = JSON.parse(jsonString); // MyType
console.log(obj.a.toUpperCase()) // okay
console.log(obj.b.toFixed(1)) // okay
obj.d // compiler error, MyType has no d property
}
async function doSomething() {
const resp = await getResponse();
example(resp.key1);
}
doSomething(); // FOO, 123.0
I don't really see much of a benefit of the proposed method over the normal method. They are both somewhat unsafe and can't deal with an unexpected JSON string. They both require someone to manually tell the compiler what type the deserialized object is, it's just that one does it before JSON.parse()
and the other does it after. The JsonSerialized<T>
code is more complicated.
The only reason why I could imagine wanting this is to put an abstraction barrier or function boundary between the code that receives the JSON string, and the code that parses that string. But that's a weird place to put a barrier. The only reasonable thing one can do with a JSON string is to parse it, and so whoever receives it might as well immediately parse it and hand back the deserialized object instead of the JSON string. And if that's the case, then the difference between const obj = JSON.parse(str) as MyType
and const obj = JSON.parse(str as JsonSerialized<MyType>)
is negligible.
Maybe you have a use case that makes JsonSerialized<T>
a better choice, but I'd advise you (and anyone else who finds this question and answer) to think carefully about whether that is really true before proceeding.
Upvotes: 7