6

I have a question about typescript optional properties of interfaces. Assuming the following code:

interface Test {
    prop1: string;
    prop2?: string;
}

function someFunction(data: {prop1: string, prop2: string}) {
    console.log(data.prop1 + ": " + data.prop2);
}

function otherFunction(data: Test) {
    if (data.prop2) {
        someFunction(data); // prop2 might be undefined!
    }
}

and having a strict mode set to true.

Typescript gives me the following error:

Argument of type 'Test' is not assignable to parameter of type '{ prop1: string; prop2: string; }'.
    Property 'prop2' is optional in type 'Test' but required in type '{ prop1: string; prop2: string; }'.

And the question is: why it is like that? Why doesn't typescript understand this if assertion?

First of all, I'd love to understand why? But also some workaround that does not produce any additional runtime code or some tons of type assertion would be a nice to have if it's possible at all?

gandalfml
  • 908
  • 1
  • 10
  • 23

5 Answers5

8

Typescript does understand typeguards like you use, the problem is that they only affect the type of the field not the whole object . So for example under strict null checks we would get the following :

function stringNotUndefined(s: string) {}


function otherFunction(data: Test) {
    stringNotUndefined(data.prop2) // error
    if (data.prop2) {
        stringNotUndefined(data.prop2) //ok
        someFunction(data); // still error
    }
}

We can create a custom type guard that will mark the checked fields as non undefined :

interface Test {
    prop1: string;
    prop2?: string;
}
function someFunction(data: { prop1: string, prop2: string }) {
    console.log(data.prop1 + ": " + data.prop2);
}

type MakeRequired<T,K extends keyof T> = Pick<T, Exclude<keyof T, K>> & {[P in K]-?:Exclude<T[P],undefined> }
function checkFields<T, K extends keyof T>(o: T | MakeRequired<T,K>, ...fields: K[]) : o is MakeRequired<T,K>{
    return fields.every(f => !!o[f]);
}

function otherFunction(data: Test) {
    if (checkFields(data, 'prop2')) {
        someFunction(data); // prop2 is now marked as mandatory, works 
    }
}

Edit

The above version may have a bit too much overhead for such a simple check. We can create a much simpler version for just one field (and use && for more fields). This version has a lot less overhead and might even be inlined if on a hot path.

interface Test {
    prop1?: string;
    prop2?: string;
}
function someFunction(data: { prop1: string, prop2: string }) {
    console.log(data.prop1 + ": " + data.prop2);
}

type MakeRequired<T,K extends keyof T> = Pick<T, Exclude<keyof T, K>> & {[P in K]-?:Exclude<T[P],undefined> }
function checkField<T, K extends keyof T>(o: T | MakeRequired<T,K>,field: K) : o is MakeRequired<T,K>{
    return !!o[field]
}

function otherFunction(data: Test) {
    if (checkField(data, 'prop2') && checkField(data, 'prop1')) {
        someFunction(data); // prop2 is now marked as mandatory, works 
    }
}
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • This is some advanced TypeScript right there. Do you happen to know what prevented the typescript team from incorporating this in the strictNullChecks feature? It seems to me that if you can implement this type check then certainly the compiler can have it too. – Stilgar Sep 01 '18 at 11:34
  • @Stilgar I have not come upon a discussion as to why they implemented it this way. The only case where a type guard impacts the owner object is with discriminated unions so it is technically possible for them to do it. I know typeguards don't work on index operations because of performance issues, maybe similar considerations came into play here. – Titian Cernicova-Dragomir Sep 01 '18 at 11:41
  • I wonder if a feature request makes sense in this case. Maybe they didn't include it simply because they added strict null checks before these advanced type features? – Stilgar Sep 01 '18 at 11:43
  • @Stilgar the advanced type features make it possible for me to implement the behavior, they would probably use different mechanisms to achieve the same result (probably more efficient :-)). You can try to add a feature request, but search the existing github issues first there might already be one (I would search myself but I'm not going to get access to a pc for a several more days, and doing it on my phone is a bit uncomfortable :-)) – Titian Cernicova-Dragomir Sep 01 '18 at 11:56
  • Now that I think of it you can implement an interface with an object that uses a getter to return prop2. Say the getter sometimes returns undefined sometimes a proper string so it might pass the if check but still return undefined later. This implementation does not contradict the interface but if the interface is passed to the function it will violate the type constraints. This might be a reason not to implement it. BTW will this pass through your type check? – Stilgar Sep 01 '18 at 11:59
  • @Stilgar if the getter is random it will pass my check, but then again it will pass the regular field type guard in my first example. – Titian Cernicova-Dragomir Sep 01 '18 at 12:05
  • Sure it will but it will not pass the compiler's check :) I mean technically the TS compiler is correct. The original code might contain a type bug uncaught by the if check and therefore it reports an error. – Stilgar Sep 01 '18 at 12:10
  • @Stilgar yes, but if the getter sometimes retuns undefined, this will compile under current TS but might still cause a runtime error `function otherFunction(data: Test) { if (data.prop2) { stringNotUndefined(data.prop2) /* ok under ts, but if getter is random not really ok */ } }` – Titian Cernicova-Dragomir Sep 01 '18 at 12:18
  • Yeah, right. So if this use case is considered acceptable tradeoff then extending the type checker to consider the use case in question makes sense. – Stilgar Sep 01 '18 at 12:26
  • Yes, this works, but... Despite the sophisticated types (which are probably acceptable) it adds additional runtime cost: new function, new lambda, forEach... This overhead for such simple operation may be too much :( – gandalfml Sep 02 '18 at 07:59
  • @gandalfml I over generalized the function, you could make a much simpler version for just one name, with a much lower runtime impact (might even be in-lined by the runtime) I'll add such a version shortly. – Titian Cernicova-Dragomir Sep 02 '18 at 11:10
  • Will the check `!!o[field]` fail for number `0`? Shouldn't there be more specific check, like `o[field] !== undefined`, or`o[field] !== undefined && o[field] !== null`? – Petr Újezdský Apr 01 '22 at 21:02
4

There is a Required built in type in Typescript that does exactly what you want:

/**
 * Make all properties in T required
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};

You can now define a type guard for data is Required<Test>:

const hasRequiredProp2 = (data: Test): data is Required<Test> => {
  return data.hasOwnProperty('prop2');
};

All you need to do is use this test using the type guard like so:

function otherFunction(data: Test) {
    if (hasRequiredProp2(data)) {
        someFunction(data); // Narrowed to Required<Test>
    }
}
Wilt
  • 41,477
  • 12
  • 152
  • 203
2

If you want a specific type guard instead of the general solution of Titian Cernicova-Dragomir, you can write a simple function.

interface TestWithProp2 {
    prop1: string;
    prop2: string; // required
}

// Special return type
function isTestWithProp2(x: Test): x is TestWithProp2 {
    return x.prop2 !== undefined;
}

// Use
if (isTestWithProp2(data)) {
    someFunction(data);
}

Source: Typescript: User defined type guards

Emaro
  • 1,397
  • 1
  • 12
  • 21
0

Typescript is statically type-checked, so the type conversion from Test to {prop1:string,prop2:string} must be valid at compile time. The if condition is evaluated at run time instead, so it cannot be used for static type-check analysis (at least not in a trivial way).

It may be possible to envisage ways to enrich Typescript so that guards could be used to allow the kind ot type casting you wish to do, but it's simply not the way it is currently designed to work (and it is more complicated than it might at first seem).

To do what you want to do, you could write a helper function that takes a parameter of type Test and returns one of type {prop1:string,prop2:string}, by filling the optional parameter with some default value if it doesn't have one in the Test parameter.

By the way, you may want to look at the discussion in Setting default value for TypeScript object passed as argument

Stefano Gogioso
  • 236
  • 2
  • 6
  • I am kind of surprised by this since TypeScript 2.0 understands if checks on null/undefined. – Stilgar Sep 01 '18 at 10:32
  • Typescript does this kind of null type guards with `if` under `strictNullChecks` the problem is what the type guard impacts. – Titian Cernicova-Dragomir Sep 01 '18 at 11:26
  • @TitianCernicova-Dragomir Afaik `data.prop2` itself is statically typed as a `string`, so there is no type-checking issue in passing it as a parameter to a function taking a `string` parameter (e.g. the `stringNotUndefined(s:string)` function in your answer---nice, btw). The optionality of `data.prop2` is encoded in the type definition for `Test`, `data.prop2` itself knows nothing about it at compile time. In contrast, similar Python code would implement optionality by decorating `data.prop2` as `Optional[string]`, and the optionality information would be carried around by `data.prop2` itself. – Stefano Gogioso Sep 01 '18 at 13:11
  • 1
    @StefanoGogioso if you use the `strictNullCheks` compiler option `null` and `undefined` are no longer assignable to `string` and the type of optional properties becomes `declaredType | undefined`. You can check this in the playground (go to options and see hover over propType before and after checking the option) https://www.typescriptlang.org/play/#src=interface%20Test%20%7B%0D%0A%20%20%20%20prop%3F%3A%20string%0D%0A%7D%0D%0A%0D%0Atype%20propType%20%3D%20Test%5B'prop'%5D – Titian Cernicova-Dragomir Sep 01 '18 at 13:33
  • 1
    @TitianCernicova-Dragomir you are completely right about the behaviour of `strictNullChecks`, my apologies for missing it in the first place. – Stefano Gogioso Sep 01 '18 at 14:26
0

Extending the @TitianCernicova-Dragomir answer with specific checks for undefined and null in case you want to distinguish them.

For undefined guard:

type MakeDefined<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
  { [P in K]-?: Exclude<T[P], undefined> };

export function ensureFieldDefined<T, K extends keyof T>(
  o: T | MakeDefined<T, K>,
  field: K
): o is MakeDefined<T, K> {
  return o[field] !== undefined;
}

For null guard:


type MakeNonNull<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
  { [P in K]: NonNullable<Exclude<T[P], undefined>> };

export function ensureFieldNotNull<T, K extends keyof T>(
  o: T | MakeNonNull<T, K>,
  field: K
): o is MakeNonNull<T, K> {
  return o[field] !== null;
}

Usage

interface Test {
    prop1?: string | null;
}

function otherFunction(data: Test) {
    if (ensureFieldDefined(data, 'prop1')) {
        someFunction(data); // prop1 is now marked as defined (prop1: string | null)
    }

    if (ensureFieldNotNull(data, 'prop1')) {
        someFunction(data); // prop1 is now marked as non-null (prop1?: string)
    }

    if (ensureFieldDefined(data, 'prop1') && ensureFieldNotNull(data, 'prop1')) {
        someFunction(data); // prop1 is now marked as fully mandatory (prop1: string)
    }
}

Thanks again @TitianCernicova-Dragomir for awesome trick :)

Petr Újezdský
  • 1,233
  • 12
  • 13