Search code examples
javascripttypescripttypeserror-handlingstrong-typing

Determining the type of a value based on a if check on another value


I want my code editor to infer the type of extraData based on the value of error which is being narrowed by the if statement:

export enum ErrorCodes {
  Unknown = 'UNKWN',
  BadRequest = 'BDREQ',
}

interface ExtraData {
  [ErrorCodes.Unknown]: string,
  [ErrorCodes.BadRequest]: number,
}

let error: ErrorCodes;
let extraData: ExtraData[typeof error];

if(error === ErrorCodes.Unknown){
  console.log(extraData) // on hover VS code says it's a string | number  
}

But if I put the extraData type assigment inside the if block then it works:

export enum ErrorCodes {
  Unknown = 'UNKWN',
  BadRequest = 'BDREQ',
}

interface ExtraData {
  [ErrorCodes.Unknown]: string,
  [ErrorCodes.BadRequest]: number,
}

let error: ErrorCodes;

if(error === ErrorCodes.Unknown){
  let extraData: ExtraData[typeof error];
  extraData // on hover VS code says it's a string  
}

I don't want to do that in every conditional block since there will be dozens of error codes and I can't validate using typeof since we will use objects as the actual extra data


Solution

  • If error and extraData are just two variables of the type ErrorCodes and ExtraData[typeof error] respectively, there's nothing you can do. ErrorCodes is a union, and so ExtraData[typeof error] is also a union type. Two union types are always treated as independent or uncorrelated with each other. The type typeof error is just ErrorCodes and doesn't "remember" anything about error. There is no way to say in types that two separately-declared variables are of correlated union types.

    So if you'd like error to be something you can check and have the check have an effect on the apparent type of extraData, you can't declare them separately. Instead they should be declared together as fields of a single discriminated union type. Here's one way to express such a type:

    type ErrorAndExtra = { [K in keyof ExtraData]:
      { error: K, extraData: ExtraData[K] }
    }[keyof ExtraData]
    
    /* type ErrorAndExtra = {
        error: ErrorCodes.Unknown;
        extraData: string;
    } | {
        error: ErrorCodes.BadRequest;
        extraData: number;
    } */
    

    The ErrorAndExtra type is a union where each member has a discriminant error property of literal type, and an extraData property whose type depends on the type of the error property.

    If you had a variable of type ErrorAndExtra, you could get the narrowing behavior you're looking for:

    declare const e: ErrorAndExtra;
    if (e.error === ErrorCodes.Unknown) {
      console.log(e.extraData.toUpperCase()) // okay
    } else {
      console.log(e.extraData.toFixed()) // okay
    }
    

    That might be sufficient for you; still, you can get the same behavior with two separate variables, as long as you destructure them from a value of the discriminated union type:

    declare const { error, extraData }: ErrorAndExtra;
    if (error === ErrorCodes.Unknown) {
      console.log(extraData.toUpperCase()) // okay
    } else {
      console.log(extraData.toFixed()) // okay
    }
    

    This also works; TypeScript understands that error and extraData came from the ErrorAndExtra discriminated union, and so the check on error has the effect on extraData you're expecting.

    Again, this only works because the declaration of error and extraData is specifically following a pattern that TypeScript supports for this purpose. If you change the pattern, it could break. For example:

    declare let { error, extraData }: ErrorAndExtra;
    if (error === ErrorCodes.Unknown) {
      console.log(extraData.toUpperCase()) // error
    } else {
      console.log(extraData.toFixed()) // error
    }
    

    Here we changed the declaration from a const to a let. And that breaks the spell. Since let variables can be reassigned, the compiler would have to track such assignments before it could verify that a check on error should have any effect on the apparent type of extraData, and it's not considered worth the extra work for the compiler to do this.

    Playground link to code