How to deal with TypeScript not treating uninitialized optional parameter as undefined

Viewed 58

Here's the simplified reproduction of my function:

const nullify = <T extends string | undefined>(
  value?: T,
): T extends string ? string : null => {
  if (value === undefined) return null as any;
  return value as any;
};

What I expect from these function typings is to treat lack of value as undefined, but TypeScript doesn't allow this, and instead it returns a union string | null. The only way to get null from this function is to explicitly pass undefined.

const result1 = nullify('string');  // result1 is string, as expected
const result2 = nullify(undefined); // result2 is null, as expected
const result3 = nullify();          // result3 is (string | null), but should be just null

Is there an idiomatic way to deal with this?

2 Answers

The problem is that when you don't pass in a value argument, there is no value from which the compiler can infer T, and inference just fails. When inference fails, it generally falls back to its constraint, which in your case is string | undefined. If you want to change what it falls back to, you can set a generic parameter default using = syntax:

const nullify = <T extends string | undefined = undefined>( 
//                      default generic ----> ^^^^^^^^^^^
    value?: T,
): T extends string ? string : null => {
    if (value === undefined) return null as any;
    return value as any;
};

And you'll only see a difference when you leave value out:

const result1 = nullify('string');  // string, as expected
const result2 = nullify(undefined); // null, as expected
const result3 = nullify();  // null, as expected 

Hooray!


There is the drawback that technically the caller can specify any value of T that they want. So nothing stops a particularly unhinged caller from doing this:

const oops = nullify<string>(); // string, oops
// ---------------> ^^^^^^^^ <--- manually specify string

Since string is manually specified for T, and the value parameter is optional, there is no compiler error. And now you have a situation where the compiler mistakenly thinks a null value is of type string.

This is probably not very likely to happen, so you could just leave it alone and not worry about it. TypeScript is actually unsound in quite a few places so you wouldn't really be making things any worse here.

Still, if you care about such things, then you need to refactor. One way is to make your generic type parameter correspond to the tuple type of a rest parameter:

const nullify = <T extends [string] | [undefined?]>(
    ...[value]: T
): T extends [string] ? string : null => {
    if (value === undefined) return null as any;
    return value as any;
};

const result1 = nullify('string');  // string, as expected
const result2 = nullify(undefined); // null, as expected
const result3 = nullify();  // null, as expected

Now you cannot easily pass something incorrect, since the only acceptable situation in which value is missing or undefined is when T extends [undefined?]:

const okayNow = nullify<[string]>(); // error, as expected

The traditional way of doing this sort of thing is to use overloads instead of (or alongside) generics, but instead of going into this, I'll just point interested parties to How to give a default value to a generic parameter? for more information.

Playground link to code

Try checking if value is string instead.

const nullify = <T extends string | undefined>(
  value?: T,
): T extends string ? string : null => {
  if (typeof value === 'string') return value as any;
  return null as any;
};

Related