TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Nested Tagged Unions

Open ccorcos opened this issue 8 years ago • 27 comments

TypeScript Version: 2.5.2

Code

type A = { type: "a", a: number }
type B = { type: "b", b: number }

type X = { type: A, a: string }
type Y = { type: B, b: string }

let x: X | Y

if (x.type.type === "a") {
	x.a // Type Error
}

Expected behavior:

I would expect x to have type X inside the if-statement after disambiguating the nested tagged union types.

Actual behavior:

ccorcos avatar Sep 26 '17 01:09 ccorcos

This isn't currently how narrowing works, but we could consider expanding its rules to cover this case

RyanCavanaugh avatar Sep 26 '17 16:09 RyanCavanaugh

Hmm. I'm surprised. So its non-recursive?

ccorcos avatar Sep 26 '17 17:09 ccorcos

Not sure if "recursive" is the right word to apply to this situation. A discriminant property only applies to the object it's directly a member of. So in your example, inside the if block, you can access x.type.a (but not x.type.b), but there are no effects on the containing object x.

RyanCavanaugh avatar Sep 26 '17 17:09 RyanCavanaugh

yeah, but given my type definition that seems incomplete, doesnt it?

ccorcos avatar Sep 26 '17 22:09 ccorcos

I agree

RyanCavanaugh avatar Sep 26 '17 23:09 RyanCavanaugh

I have run across something similar a few times, where having to peel off/unwrap properties (DOM events come to mind) and operate/assert on different parts and pieces.

It does seem to be a similar problem space to #11117, where the value of one property effects the type of another, though obviously with the above the compiler could already deduce that without any additional syntax, which #11117 would likely require.

kitsonk avatar Sep 27 '17 06:09 kitsonk

I've been doing some digging in src/compiler/checker.ts and I guess the fix should be somewhere around here:

function isMatchingReference(source: Node, target: Node): boolean {
  switch (source.kind) {
    case SyntaxKind.Identifier:
      return target.kind === SyntaxKind.Identifier && getResolvedSymbol(<Identifier>source) === getResolvedSymbol(<Identifier>target) ||
        (target.kind === SyntaxKind.VariableDeclaration || target.kind === SyntaxKind.BindingElement) &&
        getExportSymbolOfValueSymbolIfExported(getResolvedSymbol(<Identifier>source)) === getSymbolOfNode(target);
        // || (target.kind === SyntaxKind.PropertyAccessExpression && whaever else that needs to be checked here) 

Of course given the complexity of this file (createTypeChecker is a single function with ~30K lines of code), I'm not sure what the impact of this change is and what else could break from making the change.

Anyway, hope this helps, I'd really like to have this working.

Cheers!

pzavolinsky avatar Jul 22 '18 15:07 pzavolinsky

I was about to create a suggestion along the lines of "Narrow Tagged Union Recursively".

With the following examples,

A contrived example,

type T = (
    {
        success: { value: true },
        field: number
    } |
    {
        success: { value: false }
    }
);
declare const t: T;
if (t.success.value) {
    /*
        `t`` not narrowed to
        {
            success: { value: true },
            field: number
        }
    */ 
    console.log(t.field); //Error
}

Less contrived,

type T = (
    {
        payIn: { success: true, information : string },
        payOut?: { otherInformation : string }
    } |
    {
        payIn: { success: false, error : string }
    }
);
declare const t: T;
if (t.payIn.success) {
    /*
        `t` not narrowed to
        {
            payIn: { success: true, information : string },
            payOut?: { otherInformation : string }
        }

        But `t.payIn` narrowed to
        { success: true, information : string }
    */
    console.log(t.payIn.information); //OK
    console.log(t.payOut); //Error
}

AnyhowStep avatar Sep 11 '18 15:09 AnyhowStep

I currently am duplicating my tags to have them at every level.

Instead of:

export type DiffWithFiles =
  { diff: ModifiedFileDiff, previousFileContent: string, fileContent: string } |
  { diff: RenamedFileDiff, previousFileContent: string, fileContent: string, } |
  { diff: NewFileDiff } |
  { diff: DeletedFileDiff }

I have to do:

export type DiffWithFiles =
  { type: "modified", diff: ModifiedFileDiff, previousFileContent: string, fileContent: string } |
  { type: "renamed",  diff: RenamedFileDiff, previousFileContent: string, fileContent: string } |
  { type: "new", diff: NewFileDiff } |
  { type: "deleted", diff: DeletedFileDiff }

Where that type is identical to the nesteddiff.type field

I'm finding writing typescript with lots of these unions to be very pleasant, this would make that process simpler and more effective. Thanks for adding these ADT features to begin with!

amilner42 avatar Feb 06 '19 06:02 amilner42

I ended up squishing my types all down to the same level to avoid these issues. One issue with this approach is the field-name collisions, which makes it feel less composable, but if every field in all the objects have unique names then it's probably just as composable.

Like:

export type DiffWithFiles =
  ModifiedDiffWithFiles |
  RenamedDiffWithFiles |
  NewDiffWithFiles |
  DeletedDiffWithFiles


export type ModifiedDiffWithFiles = ModifiedFileDiff & {
  previousFileContent: string,
  fileContent: string
}

I haven't thought about the nesting enough to know all the implications.

I just wanted to post this so you see how the issue comes up in the real world and one possible way of people fixing it so you see what the current version of typescript is encouraging. There may be some negatives as well to allowing this nesting of unions, I'm not sure, I know in elm-lang they try to avoid nesting in general for various reasons. But if we don't have the nesting it seems that we need a way to deal with the name collision problem so we can & things together.

amilner42 avatar Feb 06 '19 19:02 amilner42

Unfortunately in my scenario I'm typing messages whose structure is beyond my control (i.e. they are produced by a different app).

I'd really like to have support for nested discriminant props to avoid having to have some monstrosity like:

export const isMessage = <T extends Message>(
  m: Message,
  action: T['operation']['action'],
): m is T =>
  m.operation.action === action;

That is used like:

  if (M.isMessage<M.FilePreview>(m, 'FILE_PREVIEW')) {
    return filePreview(m);
  }

When this could've been as simple as:

if (m.operation.action === 'FILE_PREVIEW') {
    return filePreview(m);
}

or even:

switch(m.operation.action) {
  case 'FILE_PREVIEW': return filePreview(m);
  ...
}

pzavolinsky avatar Feb 07 '19 00:02 pzavolinsky

Even if this worked only with an object spread, that would help in some unambiguously valid cases:

type A = { b: 'c' | 'd'; };
type B = { b: 'c' };
const e =
    (a: A) => {
        if (a.b === 'c') {
            const f: B = { ...a }; // Error: Type '{ b: "c" | "d"; }' is not assignable to type 'B'.
        }
    };

https://www.typescriptlang.org/play/#src=type%20A%20%3D%20%7B%20b%3A%20'c'%20%7C%20'd'%3B%20%7D%3B%0D%0Atype%20B%20%3D%20%7B%20b%3A%20'c'%20%7D%3B%0D%0Aconst%20e%20%3D%0D%0A%20%20%20%20(a%3A%20A)%20%3D%3E%20%7B%0D%0A%20%20%20%20%20%20%20%20if%20(a.b%20%3D%3D%3D%20'c')%20%7B%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20const%20f%3A%20B%20%3D%20%7B%20...a%20%7D%3B%20%2F%2F%20Error%3A%20Type%20'%7B%20b%3A%20%22c%22%20%7C%20%22d%22%3B%20%7D'%20is%20not%20assignable%20to%20type%20'B'.%0D%0A%20%20%20%20%20%20%20%20%7D%0D%0A%20%20%20%20%7D%3B%0D%0A

jeremybparagon avatar Apr 03 '19 14:04 jeremybparagon

I'd like to add another simple case which confusingly does not compile.

interface IT {
  v?: string;
}

const x: IT = {
  v: 'some'
};

let y: Required<IT>;
if (x.v) {
  y = x;
}
error TS2322: Type 'IT' is not assignable to type 'Required<IT>'.
  Types of property 'v' are incompatible.
    Type 'string | undefined' is not assignable to type 'string'.
      Type 'undefined' is not assignable to type 'string'.

21   y = x;

pelepelin avatar May 13 '19 18:05 pelepelin

Just wanted to add my voice as one more person who's just had this problem come up in my code! Just going to have to throw in a bunch of type coercions for now, it seems... (Edit: Later will come back and include a link to the particular PR of mine where I had to work around this, but it's not PR'd yet.)

haltman-at avatar Jul 03 '19 23:07 haltman-at

For those curious, this is the PR of mine where I ran into this problem, and this is the particular file where it couldn't do the necessary inference; see specifically lines 19 and 63 as the switch statements it couldn't deal with.

haltman-at avatar Jul 05 '19 22:07 haltman-at

I am encountering this issue with a Redux reducer with nesting switch statements 😢

marcandrews avatar Jul 29 '19 01:07 marcandrews

Here is an example on TypeScript Playground. Please refer to lines 43 and 46.

marcandrews avatar Jul 29 '19 13:07 marcandrews

any updates?

kevinluvian avatar Aug 27 '19 13:08 kevinluvian

+1 here. I debugged this for a bit to realize that unions can only be discriminated at the top level / direct. I'm also copying fields down the chain for this purpose

tejasmanohar avatar Nov 01 '19 22:11 tejasmanohar

Hi, folks, if you are interested in this issue, could you do me a favour? Help check the test cases added in PR https://github.com/microsoft/TypeScript/pull/38839.

For each test file(end with .ts), it would generate .types, .symbols and errors.txt file. I think .types file is the only file need to check.

I have found some cases are not correct(pointed out similar case by TS team member), but it is easy to miss the error.

ShuiRuTian avatar Feb 14 '21 14:02 ShuiRuTian

+1. Just ran into this. I'm trying to type an object that's not in my control (comes from a third-party API response), which looks as follows (compiles correctly, but doesn't behave as expected):

{
  type: { name: 'foo' };
  settings: { a: boolean; b: string }
}
| {
  type: { name: 'bar' };
  settings: { c: string[] }
}
| {
  type: { name: 'baz' };
  settings: { d: number[] }
}

Would love to express this correctly.

Edit: For others who might stumble across this: I was able to achieve what I wanted with User Defined Type Guards. Needed a lot of boilerplate (thanks to copilot for helping out), but at least got the job done.

type SettingType<Name extends string, Settings> = {
  type: { name: Name }
  settings: Settings
};
type SettingFoo = SettingType<'foo', { a: boolean; b: string }>
type SettingBar = SettingType<'bar', { c: string[] }>
type SettingBaz = SettingType<'baz', { d: number[] }>
type Settings =
  | SettingFoo
  | SettingBar
  | SettingBaz;
const isSettingFoo = (s: Settings): s is SettingFoo => s.type.name === 'foo';
const isSettingBar = (s: Settings): s is SettingBar => s.type.name === 'bar'; 
const isSettingBaz = (s: Settings): s is SettingBaz => s.type.name === 'baz'; 

rakeshpai avatar Dec 07 '21 06:12 rakeshpai

:wave: currently there is a way (with some heavy type foo) to actually get this to work, I'll use @rakeshpai example, but others should work as well:

// The nested tagged union type
type X =
  | { type: { name: 'foo' }; settings: { a: boolean; b: string } }
  | { type: { name: 'bar' }; settings: { c: string[] } }
  | { type: { name: 'baz' }; settings: { d: number[] } };

// ========================================================================== //

// The type-level function that, given a name, returns the type(s) within the union for that name.
// The second argument is required to get "distributive conditional types" to work (more on this after
// this code snippet)
type GetTypeForName<TName, TX = X> = TX extends { type: { name: TName } }
  ? TX
  : never;

// Type predicate to run the narrowing in the outer object based on a nested discriminant
const is = <TName extends X['type']['name']>(
  x: X,
  name: TName,
): x is GetTypeForName<TName> => x.type.name === name;

// ========================================================================== //

// The function you actually wanted to write, all of the above is write-once boilerplate
const test = (x: X) => {
  // What you would like out of the box:
  // if (x.type.name === 'baz') console.log(x.settings.d);

  // What you can get, for now:
  if (is(x, 'baz')) console.log(x.settings.d);
};

You can read more about distributive conditional types here.

Also, I covered some of the building blocks for this solution :point_up: in this fan fiction story about typescript metaprogramming.

Hope this helps!

pzavolinsky avatar Jul 01 '22 15:07 pzavolinsky

Just adding my case to this.

type Int = {
  settings: {
    fieldRule: 'Int'
  }
  selections: {
    integer: number
  }
}

type Dec = {
  settings: {
    fieldRule: 'Dec'
  }
  selections: {
    decimal: number
  }
}

function f(val: Int | Dec) {
  if (val.settings.fieldRule === 'Int') {
    console.log(val.selections.integer) // <-- error TS2339: Property 'integer' does not exist on type '{ integer: number; } | { decimal: number; }'.   Property 'integer' does not exist on type '{ decimal: number; }'.
  }
}

Threnos avatar Dec 24 '22 16:12 Threnos

FWIW, it is possible to chain up the narrowing with some silly-looking code:

if (x.type.type === "a") {
    if (x.type === x.type) {
	x.a // No type error
    }
}

taralx avatar Jan 16 '23 02:01 taralx

Adding my 2¢ here. Explicit type guards work fine to discriminate by a sub property. It would be great if implicit type guards did as well.

Playground

type A = {
    type: {
        name: "A"
    }
    a: number
}

type B = {
    type: {
        name: "B"
    }
    b: number
}

declare const aOrB: A | B

if (aOrB.type.name === "A") { // implicitly means that `aOrB` is `{ type: { name: "A" }}`
    console.log(aOrB.a) // Error
}

function hasTypeName<Name extends string>(a: { type: { name: string }}, name: Name): a is { type: { name: Name }} {
    return a.type.name === name
}

if (hasTypeName(aOrB, "A")) { // explicitly means that `aOrB` is `{ type: { name: "A" }}`
    console.log(aOrB.a) // Works
}

ericbf avatar Mar 07 '23 22:03 ericbf

Can it only support (string | number)[]

crazyair avatar Aug 03 '23 03:08 crazyair

As I understand it, this issue is a subset of #42384. However while propagating the inference to the parent seems very complex to implement, being able to use a nested property as a disciminator seems quite a bit simpler while covering most (?) usecases.

MichalMarsalek avatar Aug 28 '24 12:08 MichalMarsalek

This is incredibly annoying and frustrating :(

My simplified issue was:

type Block = {
  type: "Element",
  tag: string,
  children: Array<Block>
} | {
  type: "Text",
  text: string
}

type BlockType = Block["type"]
type DatabaseBlock<T extends BlockType> = {
  id: string // database id
  block: Block & { type: T }
}

let foo: DatabaseBlock<BlockType> = null!;

const handle_block: {
    [K in BlockType]: (block: DatabaseBlock<K>) => void
} = {
    Text: (block) => block.block.text, // no errors
    Element: (block) => block.block.children, // no errors
}

const handler = handle_block[foo.block.type] // no error

handler(foo.block) // error

The only work around is to basically do

const handler = handle_block[foo.block.type] as (block: DatabaseBlock<BlockType>) => unknown

which is a bit more unsafe

AlbertMarashi avatar Oct 04 '24 17:10 AlbertMarashi

I'd love this to be possible without a dedicated function. Even if I'd have to use dedicated syntax for this - e.g. something like this - it would go a long way:

// FAKE CODE, DO NOT USE

class BaseShape<T extends string> {
  public internal: {
    kind: T
  }
  // ...
}

class Circle extends BaseShape<"circle"> {
  public radius: number = 10;
}

class Square extends BaseShape<"square"> {
  public x: number = 10;
}

class Triamgle extends BaseShape<"triangle"> {
  public x: number = 10;
  public y: number = 10;
}

type Shape = DeepInfer<Circle | Square  | Triamgle>;
// or
type Shape = DeepInfer<Circle | Square  | Triamgle, 'internal.kind'>;

// User code:

if(a.internal.kind === "circle") {
  console.log('radius is: ', a.radius); // ts should look for nested properties to infer type of the union and assume only Circle can fit
} 

Why does this matter to me? I am working on a SDK, that has typegen built in (kinda like ORM for web api). Search results of the sdk can be of any type, but each has dedicated type name described in internal object property. I can take extra code generation into typegen for recursive infering as long as user doesn't have to do anything in their code.

rgembalik avatar Dec 12 '24 11:12 rgembalik

I don't know if performance is one of the reasons deeper nested discriminators having an effect on levels above in an object tree hasn't been realized as of yet. I'm guessing yes? If so, would tsgo's release pave the way to explore this again?

Anoesj avatar Jun 22 '25 09:06 Anoesj