Skip to content

Commit 724c1cc

Browse files
committed
Make Item<D> distributive so unions narrow on __itemTypeId
`Item<A | B>` previously collapsed into a single object whose `__itemTypeId` and `attributes` were independent unions, so narrowing on `__itemTypeId` told TS nothing about `attributes`. Wrapping the body in `D extends ItemTypeDefinition ? { … } : never` makes `D` a naked type parameter inside a conditional, which forces distribution: `Item<A | B>` now evaluates as `Item<A> | Item<B>` — a real discriminated union.
1 parent c368fd2 commit 724c1cc

5 files changed

Lines changed: 246 additions & 85 deletions

File tree

generate/templates/ApiTypes.ts.handlebars

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -50,51 +50,63 @@
5050
};
5151

5252
{{#if simpleVariant}}
53-
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> = {
54-
__itemTypeId?: D['itemTypeId'];
55-
id: ItemIdentity;
56-
type: ItemType1;
57-
item_type: ItemTypeData<D>;
58-
creator?:
59-
| AccountData
60-
| AccessTokenData
61-
| UserData
62-
| SsoUserData
63-
| OrganizationData;
64-
meta: ItemMeta;
65-
} & ToItemAttributes<D>;
53+
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> =
54+
D extends ItemTypeDefinition
55+
? {
56+
__itemTypeId?: D['itemTypeId'];
57+
id: ItemIdentity;
58+
type: ItemType1;
59+
item_type: ItemTypeData<D>;
60+
creator?:
61+
| AccountData
62+
| AccessTokenData
63+
| UserData
64+
| SsoUserData
65+
| OrganizationData;
66+
meta: ItemMeta;
67+
} & ToItemAttributes<D>
68+
: never;
6669

67-
export type ItemInNestedResponse<D extends ItemTypeDefinition = ItemTypeDefinition> = {
68-
__itemTypeId?: D['itemTypeId'];
69-
id: ItemIdentity;
70-
type: ItemType1;
71-
item_type: ItemTypeData<D>;
72-
creator?:
73-
| AccountData
74-
| AccessTokenData
75-
| UserData
76-
| SsoUserData
77-
| OrganizationData;
78-
meta: ItemMeta;
79-
} & ToItemAttributesInNestedResponse<D>;
70+
export type ItemInNestedResponse<D extends ItemTypeDefinition = ItemTypeDefinition> =
71+
D extends ItemTypeDefinition
72+
? {
73+
__itemTypeId?: D['itemTypeId'];
74+
id: ItemIdentity;
75+
type: ItemType1;
76+
item_type: ItemTypeData<D>;
77+
creator?:
78+
| AccountData
79+
| AccessTokenData
80+
| UserData
81+
| SsoUserData
82+
| OrganizationData;
83+
meta: ItemMeta;
84+
} & ToItemAttributesInNestedResponse<D>
85+
: never;
8086
{{else}}
81-
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> = {
82-
__itemTypeId?: D['itemTypeId'];
83-
type: ItemType1;
84-
id: ItemIdentity;
85-
relationships: ItemRelationships<D>;
86-
meta: ItemMeta;
87-
attributes: ToItemAttributes<D>;
88-
};
87+
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> =
88+
D extends ItemTypeDefinition
89+
? {
90+
__itemTypeId?: D['itemTypeId'];
91+
type: ItemType1;
92+
id: ItemIdentity;
93+
relationships: ItemRelationships<D>;
94+
meta: ItemMeta;
95+
attributes: ToItemAttributes<D>;
96+
}
97+
: never;
8998

90-
export type ItemInNestedResponse<D extends ItemTypeDefinition = ItemTypeDefinition> = {
91-
__itemTypeId?: D['itemTypeId'];
92-
type: ItemType1;
93-
id: ItemIdentity;
94-
relationships: ItemRelationships<D>;
95-
meta: ItemMeta;
96-
attributes: ToItemAttributesInNestedResponse<D>;
97-
};
99+
export type ItemInNestedResponse<D extends ItemTypeDefinition = ItemTypeDefinition> =
100+
D extends ItemTypeDefinition
101+
? {
102+
__itemTypeId?: D['itemTypeId'];
103+
type: ItemType1;
104+
id: ItemIdentity;
105+
relationships: ItemRelationships<D>;
106+
meta: ItemMeta;
107+
attributes: ToItemAttributesInNestedResponse<D>;
108+
}
109+
: never;
98110
{{/if}}
99111
{{/if}}
100112

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { ApiTypes, ItemTypeDefinition, RawApiTypes } from '../src';
2+
3+
// ---------------------------------------------------------------------------
4+
// Schema mirroring the project's `Schema.AnyModel` shape (Article, Author, …)
5+
// ---------------------------------------------------------------------------
6+
7+
type Settings = { locales: 'en' };
8+
9+
type Article = ItemTypeDefinition<
10+
Settings,
11+
'article-uuid',
12+
{
13+
title: { type: 'string' };
14+
slug: { type: 'slug' };
15+
}
16+
>;
17+
18+
type Author = ItemTypeDefinition<
19+
Settings,
20+
'author-uuid',
21+
{
22+
name: { type: 'string' };
23+
bio: { type: 'text' };
24+
}
25+
>;
26+
27+
type Category = ItemTypeDefinition<
28+
Settings,
29+
'category-uuid',
30+
{
31+
label: { type: 'string' };
32+
}
33+
>;
34+
35+
type AnyModel = Article | Author | Category;
36+
37+
// ---------------------------------------------------------------------------
38+
// Type-level assertion helpers
39+
// ---------------------------------------------------------------------------
40+
41+
type IsTrue<T extends true> = T;
42+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B
43+
? 1
44+
: 2
45+
? true
46+
: false;
47+
48+
// ---------------------------------------------------------------------------
49+
// `Item<D>` and `ItemInNestedResponse<D>` are now distributive, so a union
50+
// argument expands into a real discriminated union of per-model items.
51+
// ---------------------------------------------------------------------------
52+
53+
type _RawItemDistributes = IsTrue<
54+
Equal<
55+
RawApiTypes.Item<AnyModel>,
56+
| RawApiTypes.Item<Article>
57+
| RawApiTypes.Item<Author>
58+
| RawApiTypes.Item<Category>
59+
>
60+
>;
61+
void (null as unknown as _RawItemDistributes);
62+
63+
type _RawItemInNestedDistributes = IsTrue<
64+
Equal<
65+
RawApiTypes.ItemInNestedResponse<AnyModel>,
66+
| RawApiTypes.ItemInNestedResponse<Article>
67+
| RawApiTypes.ItemInNestedResponse<Author>
68+
| RawApiTypes.ItemInNestedResponse<Category>
69+
>
70+
>;
71+
void (null as unknown as _RawItemInNestedDistributes);
72+
73+
type _ApiItemDistributes = IsTrue<
74+
Equal<
75+
ApiTypes.Item<AnyModel>,
76+
ApiTypes.Item<Article> | ApiTypes.Item<Author> | ApiTypes.Item<Category>
77+
>
78+
>;
79+
void (null as unknown as _ApiItemDistributes);
80+
81+
type _ApiItemInNestedDistributes = IsTrue<
82+
Equal<
83+
ApiTypes.ItemInNestedResponse<AnyModel>,
84+
| ApiTypes.ItemInNestedResponse<Article>
85+
| ApiTypes.ItemInNestedResponse<Author>
86+
| ApiTypes.ItemInNestedResponse<Category>
87+
>
88+
>;
89+
void (null as unknown as _ApiItemInNestedDistributes);
90+
91+
// ---------------------------------------------------------------------------
92+
// Discriminated-union narrowing on `__itemTypeId` works on the plain
93+
// `Item<AnyModel>` — no helper required.
94+
// ---------------------------------------------------------------------------
95+
96+
function _narrowingWorksOnRawItem(item: RawApiTypes.Item<AnyModel>) {
97+
if (item.__itemTypeId === 'author-uuid') {
98+
const _name: string | null | undefined = item.attributes.name;
99+
void _name;
100+
// @ts-expect-error — Author has no `title`
101+
item.attributes.title;
102+
}
103+
104+
if (item.__itemTypeId === 'article-uuid') {
105+
const _title: string | null | undefined = item.attributes.title;
106+
void _title;
107+
// @ts-expect-error — Article has no `name`
108+
item.attributes.name;
109+
}
110+
}
111+
112+
function _narrowingWorksOnApiItem(item: ApiTypes.Item<AnyModel>) {
113+
if (item.__itemTypeId === 'category-uuid') {
114+
const _label: string | null | undefined = item.label;
115+
void _label;
116+
// @ts-expect-error — Category has no `name`
117+
item.name;
118+
}
119+
}
120+
121+
function _narrowingWorksOnNestedResponse(
122+
item: RawApiTypes.ItemInNestedResponse<AnyModel>,
123+
) {
124+
if (item.__itemTypeId === 'author-uuid') {
125+
const _bio: unknown = item.attributes.bio;
126+
void _bio;
127+
}
128+
}
129+
130+
// ---------------------------------------------------------------------------
131+
// Runtime placeholder so Jest doesn't complain about an empty test file.
132+
// All real assertions above are compile-time.
133+
// ---------------------------------------------------------------------------
134+
135+
describe('Item union narrowing (type-level)', () => {
136+
it('compiles — see type assertions in this file', () => {
137+
expect(true).toBe(true);
138+
});
139+
});

packages/cma-client/src/fieldTypes/single_block.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export type BlockInRequest<D extends ItemTypeDefinition = ItemTypeDefinition> =
107107

108108
export type BlockInNestedResponse<
109109
D extends ItemTypeDefinition = ItemTypeDefinition,
110-
> = D extends unknown ? RawApiTypes.ItemInNestedResponse<D> : never;
110+
> = RawApiTypes.ItemInNestedResponse<D>;
111111

112112
/**
113113
* Single Block field value for API requests - allows flexible block representations:

packages/cma-client/src/generated/ApiTypes.ts

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,40 @@ export type ItemTypeData<D extends ItemTypeDefinition = ItemTypeDefinition> = {
2727
id: D extends ItemTypeDefinition ? D['itemTypeId'] : ItemTypeIdentity;
2828
};
2929

30-
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> = {
31-
__itemTypeId?: D['itemTypeId'];
32-
id: ItemIdentity;
33-
type: ItemType1;
34-
item_type: ItemTypeData<D>;
35-
creator?:
36-
| AccountData
37-
| AccessTokenData
38-
| UserData
39-
| SsoUserData
40-
| OrganizationData;
41-
meta: ItemMeta;
42-
} & ToItemAttributes<D>;
30+
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> =
31+
D extends ItemTypeDefinition
32+
? {
33+
__itemTypeId?: D['itemTypeId'];
34+
id: ItemIdentity;
35+
type: ItemType1;
36+
item_type: ItemTypeData<D>;
37+
creator?:
38+
| AccountData
39+
| AccessTokenData
40+
| UserData
41+
| SsoUserData
42+
| OrganizationData;
43+
meta: ItemMeta;
44+
} & ToItemAttributes<D>
45+
: never;
4346

4447
export type ItemInNestedResponse<
4548
D extends ItemTypeDefinition = ItemTypeDefinition,
46-
> = {
47-
__itemTypeId?: D['itemTypeId'];
48-
id: ItemIdentity;
49-
type: ItemType1;
50-
item_type: ItemTypeData<D>;
51-
creator?:
52-
| AccountData
53-
| AccessTokenData
54-
| UserData
55-
| SsoUserData
56-
| OrganizationData;
57-
meta: ItemMeta;
58-
} & ToItemAttributesInNestedResponse<D>;
49+
> = D extends ItemTypeDefinition
50+
? {
51+
__itemTypeId?: D['itemTypeId'];
52+
id: ItemIdentity;
53+
type: ItemType1;
54+
item_type: ItemTypeData<D>;
55+
creator?:
56+
| AccountData
57+
| AccessTokenData
58+
| UserData
59+
| SsoUserData
60+
| OrganizationData;
61+
meta: ItemMeta;
62+
} & ToItemAttributesInNestedResponse<D>
63+
: never;
5964

6065
/* tslint:disable */
6166
/**

packages/cma-client/src/generated/RawApiTypes.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,30 @@ export type ItemTypeData<D extends ItemTypeDefinition = ItemTypeDefinition> = {
3939
id: D extends ItemTypeDefinition ? D['itemTypeId'] : ItemTypeIdentity;
4040
};
4141

42-
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> = {
43-
__itemTypeId?: D['itemTypeId'];
44-
type: ItemType1;
45-
id: ItemIdentity;
46-
relationships: ItemRelationships<D>;
47-
meta: ItemMeta;
48-
attributes: ToItemAttributes<D>;
49-
};
42+
export type Item<D extends ItemTypeDefinition = ItemTypeDefinition> =
43+
D extends ItemTypeDefinition
44+
? {
45+
__itemTypeId?: D['itemTypeId'];
46+
type: ItemType1;
47+
id: ItemIdentity;
48+
relationships: ItemRelationships<D>;
49+
meta: ItemMeta;
50+
attributes: ToItemAttributes<D>;
51+
}
52+
: never;
5053

5154
export type ItemInNestedResponse<
5255
D extends ItemTypeDefinition = ItemTypeDefinition,
53-
> = {
54-
__itemTypeId?: D['itemTypeId'];
55-
type: ItemType1;
56-
id: ItemIdentity;
57-
relationships: ItemRelationships<D>;
58-
meta: ItemMeta;
59-
attributes: ToItemAttributesInNestedResponse<D>;
60-
};
56+
> = D extends ItemTypeDefinition
57+
? {
58+
__itemTypeId?: D['itemTypeId'];
59+
type: ItemType1;
60+
id: ItemIdentity;
61+
relationships: ItemRelationships<D>;
62+
meta: ItemMeta;
63+
attributes: ToItemAttributesInNestedResponse<D>;
64+
}
65+
: never;
6166

6267
/* tslint:disable */
6368
/**

0 commit comments

Comments
 (0)