Consider the below code, paying special attention to the cat
property on the interface:
interface Token {
created: string;
expires: string;
id: string;
lastUsed: string;
role: string;
updated: string;
user: string;
cat: number;
};
export async function getAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) {
return null;
}
return JSON.parse(token) as Token;
} catch (error) { return null; }
}
Of course, cat
is not going to be on the actual token, so why doesn't typescript complain that my return value does not match my interface?
Thank you!
CodePudding user response:
The TypeScript compiler doesn't know anything about token
other than the fact that it's going to be some string
at runtime. And even if somehow knew the exact literal type that token
would be, the standard library typing for the JSON.parse()
method doesn't attempt to figure out what the shape of the returned value would be. Instead, it just returns the unsafe any
type:
declare var JSON: JSON;
interface JSON {
parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;
}
The any
type is an "escape hatch" for the type system and essentially turns off type checking. So in const ret = JSON.parse(token)
, ret
is of type any
, and suddenly any crazy thing is permitted:
const ret: any = JSON.parse(token);
ret.cat.toUpperCase(); // no error
ret.dog.bark(); // no error
ret.cat.bat.rat.gnat.wombat.muskrat.meerkat.polecat.goat.stoat.meat; // no error
This isn't great, but that's the way it is. There's not much point trying to make the return type depend on the literal type of the input, since that would be very complicated and mostly useless, since the TypeScript compiler checks code well before runtime and so will almost never know very much about the input value.
There is also no built-in json
type corresponding to just valid JSON.parse()
outputs, although there's a longstanding open issue at microsoft/TypeScript#1897 asking for this.
It would be safer if JSON.parse()
returned the unknown
type instead of any
, but the typings for JSON.parse()
pre-dates the introduction of the safer unknown
type in TypeScript 3.0... and changing it now would be a large breaking change.
If you have a safer alternative for the JSON.parse()
call signature that you want to use instead, you can merge it in yourself. For example:
// merge it in
interface JSON {
parse(x: string): unknown;
}
Now you would get a compiler error if you return JSON.parse(token)
directly:
async function getAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) { return null; }
const ret = JSON.parse(token);
return ret; // error! Type 'unknown' is not assignable to type 'Token | null'
} catch (error) { return null; }
}
Note that even without the any
type, you can still suppress compiler errors by using a type assertion:
async function getAssertAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) { return null; }
const ret = JSON.parse(token);
return ret as Token; // okay
} catch (error) { return null; }
}
That's because TypeScript has assertions, not "cast"s... or at least the term "cast" is ambiguous enough to be confusing. A type assertion is like a "static cast" and just tells the compiler that it's okay to treat a value as having a particular type. Think of it as you giving the compiler information that it doesn't have. If you think the result of JSON.parse(token)
will be a valid Token
but the compiler doesn't know this, then you can write JSON.parse(token) as Token
to tell it so. It is absolutely not a "runtime cast" that performs any kind of coercion at runtime. TypeScript's type system is erased from the emitted JavaScript, so any runtime coercion needs to involve JavaScript code. You can write String(x)
to turn x
into a string
at runtime (and the compiler understands this) but x as string
has no such effect; it's a prediction, not an operation.
So, if you don't use a type assertion and you don't use any
, you will probably be in the position where the compiler complains that it can't be sure that the value is really a Token
. At this point the only way forward would be to perform a thorough runtime check of the output in a way that the compiler understands to be narrowing from unknown
all the way down to Token
:
function hasPropOfType<T extends object, K extends PropertyKey, V>(
obj: T, key: K, valGuard: (x: any) => x is V): obj is T & Record<K, V> {
return valGuard((obj as any)[key]);
}
function isString(x: any): x is string { return typeof x === "string" };
function isNumber(x: any): x is number { return typeof x === "number" };
async function getAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) {
return null;
}
const ret = JSON.parse(token);
if (!ret) return null;
if (typeof ret !== "object") return null;
if (!hasPropOfType(ret, "created", isString)) return null;
if (!hasPropOfType(ret, "expires", isString)) return null;
if (!hasPropOfType(ret, "id", isString)) return null;
if (!hasPropOfType(ret, "lastUsed", isString)) return null;
if (!hasPropOfType(ret, "role", isString)) return null;
if (!hasPropOfType(ret, "updated", isString)) return null;
if (!hasPropOfType(ret, "user", isString)) return null;
if (!hasPropOfType(ret, "cat", isNumber)) return null;
return ret; // okay
} catch (error) { return null; }
}
Here we've written some custom type guard functions and used them a bunch of times until the compiler is convinced on its own that ret
is of type Token
. If you're interested in this sort of approach, you might want to look at Typescript check object by type or interface at runtime with typeguards in 2020
So, there you go. TypeScript doesn't complain that cat
doesn't exist on JSON.parse(token) as Token
because:
- it doesn't know anything about
token
other than it's astring
JSON.parse()
's return type isany
, which makes the compiler accept any crazy thing- even if you make it return something safer like
unknown
, the type assertionas Token
serves to suppress errors, not generate them