10

Given the following types:

interface FullName {
  fullName?: string
}

interface Name {
  firstName: string
  lastName: string
}

type Person = FullName | Name;

const p1: Person = {};
const p2: Person = { fullName: 'test' };
const p3: Person = { firstName: 'test' }; // Does not throw
const p4: Person = { badProp: true }; // Does throw, as badProp is not on FullName | Name;

I would expect p3 to result in a compiler error, as firstName is present without lastName, but it doesn't -- is this a bug or expected?

Additionally, making FullName.fullName required results in p3 (and p1) causing errors.

Ben Southgate
  • 3,388
  • 3
  • 22
  • 31
  • Probably a separate question, but is there a way to enforce an optional union? That is, say `FullName` did require `fullName`. Is there a way to enforce `Person` requiring (`fullName`) OR (`firstName` and `lastName`) OR neither? – Christian Yang Jan 05 '17 at 22:25

3 Answers3

6

First of, your interface FullName does only contain one optional property, that is basically making it match anything. Then when you do a union type with it, the resulting type is going to be compatible with everything.

However, there is another concern considering declaring and assigning literal objects, and that is that you only can declare known properties: Why am I getting an error "Object literal may only specify known properties"?

So you could do this without any problem:

var test = { otherStuff: 23 };
const p4: Person = test;

But not this

const p4: Person = { otherStuff: 23 };

And in your case firstName is a known property of FullName | Name, so it's all ok.

And as @artem answered, discriminated unions have a special meaning in typescript, apart from regular unions, requiring special structural assumptions.

Community
  • 1
  • 1
Alex
  • 14,104
  • 11
  • 54
  • 77
  • I could be wrong, but it doesn't seem like an interface with an optional property results in the type being equivalent to `any`. For example, if that were the case setting `const p4: Person = { badProp: true }` would not cause an error? – Ben Southgate Jan 05 '17 at 22:32
  • @BenSouthgate No, it's not totally equivalent with any, bad wording on my part. – Alex Jan 05 '17 at 22:40
  • @BenSouthgate And to extend on that: All objects match the empty type {}, just like they would match a type with only one optional property. However, declaring a object literal is another question. Here we have another constraint, and that is that we can only declare properties that matches the type the declared object is being assigned to. It does **not** mean that the object does not actually fulfill the target type. – Alex Jan 06 '17 at 01:19
  • And a type with an single optional property is not equivalent with `any` because `any` can be seen to have all possible properties that an object can have, each being optional. So if your union type would contain a `any`, all properties would be allowed to declare in the object literal targeting that union type. That is not the case for the type with the lone optional property. Although, they do seem to disregard from the rule about only declaring known properties if one of the constituents are completely empty. like `{}|Name`. – Alex Jan 06 '17 at 01:28
2

The type in your question is not, in usual sense, discriminated union - your union members don't have common, non-optional literal property called discriminant.

So, as @Alex noted in his answer, your union is somewhat similar to

type Person = {
  fullName?: string
  firstName?: string
  lastName?: string
}

so it can be initialized with { firstName: 'test' }

With true discriminated unions, you get back the logic for checking non-optional properties, as well as checking that object literal may only specify known properties:

interface FullName {
  kind: 'fullname';  
  fullName?: string
}

interface Name {
  kind: 'name';  
  firstName: string
  lastName: string
}

type Person = FullName | Name;

const p1: Person = {kind: 'fullname'};  // ok
const p2: Person = {kind: 'fullname', fullName: 'test' };  // ok

checking non-optional property:

const p3: Person = {kind: 'name', firstName: 'test' }; 

error:

Type '{ kind: "name"; firstName: string; }' is not assignable to type 'Person'.
  Type '{ kind: "name"; firstName: string; }' is not assignable to type 'Name'.
    Property 'lastName' is missing in type '{ kind: "name"; firstName: string; }'.

checking extra property:

const p5: Person = { kind: 'fullname', bar: 42 }

error:

Type '{ kind: "fullname"; bar: number; }' is not assignable to type 'Person'.
  Object literal may only specify known properties, and 'bar' does not exist in type 'Person'.

However, as @JeffMercado found out, type checking is still a bit off:

const p6: Person = { kind: 'fullname', firstName: 42 };  // no error. why?

I'd consider posting an issue for typescript github project.

artem
  • 46,476
  • 8
  • 74
  • 78
  • 1
    Oddly enough, this also doesn't report errors with the original: `const p5: Person = { firstName: 42 };` – Jeff Mercado Jan 05 '17 at 22:46
  • I think I know why. You aren't actually taking advantage of the discriminated union pattern here. `kind` is just like a propertly like all the rest, but they have different types (string literal types) in both FullName and Name. p3 don't work because it's not compatible with neither of the constituents. p5 does not work because bar is not a known property of any of the constituents. However, p6 is fine because it's compatible with `FullName` and `firstName` is a known property of one of the constituents. – Alex Jan 06 '17 at 00:56
  • To actually "activate" the discriminated union, you have to type guard it like they do in the part of the documentation you linked to. It's first then TS will narrow the type accordingly. – Alex Jan 06 '17 at 00:57
0

2021 Update: The example in the question now works as intended.

Since at least TypeScript version 3.3.3 (the oldest version one can currently test on the TypeScript playground) you don't need a discriminant (i.e. a common, non-optional literal property).

Given

interface FullName {
  fullName?: string
}

interface Name {
  firstName: string
  lastName: string
}

type Person = FullName | Name;

as in the question, the following example (marked as "Does not throw" by the person asking this question)

const p3: Person = { firstName: 'test' }; // Does not throw

now leads to this TypeScript error:

Property 'lastName' is missing in type '{ firstName: string; }' but required in type 'Name'.

And @artem was wondering why

const p6: Person = { kind: 'fullname', firstName: 42 };

would not throw an error in his example with the discriminant kind.

Well, since at last TypeScript version 3.3.3 it does throw an error, precisely the expected one:

Object literal may only specify known properties, and 'firstName' does not exist in type 'FullName'.

See this TypeScript playground which includes both examples (with and without discriminated union).

Andru
  • 5,954
  • 3
  • 39
  • 56