Home > Back-end >  Why doesn't Typescript detect that my return value doesn't match my interface?
Why doesn't Typescript detect that my return value doesn't match my interface?

Time:10-13

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 a string
  • JSON.parse()'s return type is any, which makes the compiler accept any crazy thing
  • even if you make it return something safer like unknown, the type assertion as Token serves to suppress errors, not generate them

Playground link to code

  • Related