-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Nested Tagged Unions #18758
Comments
This isn't currently how narrowing works, but we could consider expanding its rules to cover this case |
Hmm. I'm surprised. So its non-recursive? |
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 |
yeah, but given my type definition that seems incomplete, doesnt it? |
I agree |
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. |
I've been doing some digging in 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 ( Anyway, hope this helps, I'd really like to have this working. Cheers! |
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
} |
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 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! |
- Added content[] to VdTags for eventual propogation to the client - Unfortunetly need repeated tags because of issue: microsoft/TypeScript#18758 so we have 2 diffTypes
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 |
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);
...
} |
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'.
}
}; |
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;
}
|
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.) |
I am encountering this issue with a Redux reducer with nesting |
Hi, folks, if you are interested in this issue, could you do me a favour? Help check the test cases added in PR #38839. For each test file(end with .ts), it would generate I have found some cases are not correct(pointed out similar case by TS team member), but it is easy to miss the error. |
+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'; |
👋 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 ☝️ in this fan fiction story about typescript metaprogramming. Hope this helps! |
Just adding my case to this.
|
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
}
} |
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. 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
} |
Can it only support |
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. |
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 |
TypeScript Version: 2.5.2
Code
Expected behavior:
I would expect x to have type
X
inside the if-statement after disambiguating the nested tagged union types.Actual behavior:
The text was updated successfully, but these errors were encountered: