Is there a way to narrow typescript overloads with optional parameters?

Viewed 965

I'm trying to do the following:

interface RgbColor {
  r: number;
  g: number;
  b: number;
}

function rgbToHex(r: RgbColor, g?: undefined, b?: undefined): string
function rgbToHex(r: number, g: number, b: number): string
function rgbToHex(r: number|RgbColor, g?: number, b?: number): string {
  if (r instanceof Object) {
    // must be RgbColor
    g = r.g;
    b = r.b;
    r = r.r;
  }

  // A) r was an RgbColor and we've already set r, g, and b to numbers in the if block
  // B) we're meeting the second overload and r, g, and b are all numbers
  // either way we know they are all numbers now

  let rHex = r.toString(16);
  let gHex = g.toString(16); // ERROR: Object is possibly 'undefined'.
  let bHex = b.toString(16); // ERROR: Object is possibly 'undefined'.

  if (rHex.length == 1) rHex = "0" + rHex;
  if (gHex.length == 1) gHex = "0" + gHex;
  if (bHex.length == 1) bHex = "0" + bHex;

  return "#" + rHex + gHex + bHex;
}

I'm getting the errors on the lines indicated by ERROR comments.

From a javascript perspective the function is fine:

function rgbToHex(r , g, b) {
  if (r instanceof Object) {
    g = r.g; 
    b = r.b; 
    r = r.r;
  }

  let rHex = r.toString(16);
  let gHex = g.toString(16);
  let bHex = b.toString(16);

  if (rHex.length == 1) rHex = "0" + rHex;
  if (gHex.length == 1) gHex = "0" + gHex;
  if (bHex.length == 1) bHex = "0" + bHex;

  return "#" + rHex + gHex + bHex;
}

rgbToHex({ r: 120, g: 50, b: 5 }) // "#783205"
rgbToHex(120, 50, 5) // "#783205"

My question is how can I setup the function signatures such that this just works without having to do any casting and without setting up any redundant variables in the function body. i.e. I want to avoid doing let gVal: number = g || (r as any).g;.

The other alternative would be to split this into two function and have one call the other; but I wouldn't do that with javascript, so it seems wrong that I'd have to do that with typescript.

TS Playground Link

Sorry if this is a duplicate. I spent a couple of hours looking through similar questions but I couldn't find anything that addressed this issue.

2 Answers

After a bit more googling, reading of other SO questions, and flicking through the typescript github issues, it looks like this is a yet-to-be-implemented feature within typescript.

The overload narrowing that works outside of a function does not yet apply within a function:

// function definition for swapping between number and string types
function foo(bar: string): number // overload 1
function foo(bar: number): string // overload 2
function foo(bar: number | string): number|string 
{ ... }

const a = foo('string');
a.toPrecision(); // TS knows a is a number, so this has no errors

const b = foo(5);
b.toLowerCase(); // TS knows b is a string, so this has no errors

const c = Math.random() < .5 ? a : b; // TS knows c as string | number

// the actual implementation of an overloaded method does not count as 
// one of the possible overloads, so calling foo with a variable of type (string|number)
// is not valid
const d = foo(c); // error on parameter 'c': No overload matches this call.

So that's all as expected. But when we try to implement foo we see the same exclusionary logic is not applied within the method:

function foo(bar: string): number // overload 1
function foo(bar: number): string // overload 2
function foo(bar: number | string): number | string {
  return bar; // this is valid, it clearly should not be
}

It looks like the typescript team have not yet made the code-analysis within a function aware of the overloads of that function, it seems like strictly the signature of the implementation is enforced. This is obviously wrong.

QED: Error in tooling. Wait for fix.

The problem is, the variables g and b will always be of type number | undefined, they can't be reassigned permanently, without declaring a new variable.

You have 4 options:

  1. Use the Non-Null Assertion Operator !, meaning instead of writing g.toString(16) you need to write g!.toString(16), forcing it to be of type number. You could also solve it with a typecast.
  2. You can separate it into multiple functions, extract the rgb variables and then return the original rgbToHex function.
  3. As mentioned in the answer of @Oblosys you can use a union type.
  4. You could create a new object at the top of the function, where you fill a new RgbColor object.
Related