Skip to content

Commit 14c0522

Browse files
Fix optional and undefined properties for custom types (#94)
This PR improves the handling of optional properties for custom types: - It fixes a bug when assigning `undefined` in explicit way to optional properties. - It fixes another bug when comparing optional custom properties for equality. - It enables to define properties for custom types using `undefined` in explicit way.
1 parent fcbbe56 commit 14c0522

File tree

6 files changed

+119
-4
lines changed

6 files changed

+119
-4
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ Note that the versions "0.x.0" probably will include breaking changes.
55
For each minor and major version, there is a corresponding [milestone on GitHub](https://github.com/TypeFox/typir/milestones).
66

77

8+
## v0.4.0 (2025-??-??)
9+
10+
[Linked issues and PRs for v0.4.0](https://github.com/TypeFox/typir/milestone/5)
11+
12+
### New features
13+
14+
- The TypeScript type of properties for custom types might use `undefined` now.
15+
16+
### Breaking changes
17+
18+
### Fixed bugs
19+
20+
- Initializing optional properties of custom types with `undefined` failed, as reported in [#77](https://github.com/TypeFox/typir/discussions/77#discussioncomment-14149139).
21+
- When checking the equality of custom types, the values for the same property might have different TypeScript types, since optional propeties might be set to `undefined`.
22+
23+
824
## v0.3.0 (2025-08-15)
925

1026
[Linked issues and PRs for v0.3.0](https://github.com/TypeFox/typir/milestone/4)

documentation/kinds/custom-types.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ This sections describes the features of custom types in more detail.
6767

6868
### Custom properties
6969

70-
Custom types have custom properties ("data") including primitive values, Typir types and nesting/grouping with sets, arrays, and maps, and recursion.
70+
Custom types have custom properties ("data") including primitive values and `undefined`, Typir types and nesting/grouping with sets, arrays, and maps, and recursion.
7171
See `custom-nested-properties.test.ts` for some examples.
72+
It is also possible to mark properties as optional with the `?` operator (see `custom-optional-properties.test.ts` for an example).
7273
When the initialization of the custom type is done, all its properties are read-only.
7374

7475
### Support by the TypeScript compiler

packages/typir/src/kinds/custom/custom-definitions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type CustomTypeProperties = {
2121
export type CustomTypePropertyTypes =
2222
| Type
2323
| string | number | boolean | bigint | symbol
24+
| undefined // to make properties optional
2425
| CustomTypePropertyTypes[] | Map<string, CustomTypePropertyTypes> | Set<CustomTypePropertyTypes>
2526
| CustomTypeProperties // recursive nesting
2627
;
@@ -42,6 +43,7 @@ export type CustomTypePropertyInitialization<T extends CustomTypePropertyTypes,
4243
T extends Type ? TypeDescriptorForCustomTypes<T, Specifics> :
4344
// unchanged for the atomic cases:
4445
T extends (string | number | boolean | bigint | symbol) ? T :
46+
T extends undefined ? T :
4547
// ... in recursive way for the composites:
4648
T extends Array<infer ValueType> ? (ValueType extends CustomTypePropertyTypes ? Array<CustomTypePropertyInitialization<ValueType, Specifics>> : never) :
4749
T extends Map<string, infer ValueType> ? (ValueType extends CustomTypePropertyTypes ? Map<string, CustomTypePropertyInitialization<ValueType, Specifics>> : never) :
@@ -61,6 +63,7 @@ export type CustomTypePropertyStorage<T extends CustomTypePropertyTypes, Specifi
6163
T extends Type ? TypeReference<T, Specifics> :
6264
// unchanged for the atomic cases:
6365
T extends (string | number | boolean | bigint | symbol) ? T :
66+
T extends undefined ? T :
6467
// ... in recursive way for the composites:
6568
T extends Array<infer ValueType> ? (ValueType extends CustomTypePropertyTypes ? ReadonlyArray<CustomTypePropertyStorage<ValueType, Specifics>> : never) :
6669
T extends Map<string, infer ValueType> ? (ValueType extends CustomTypePropertyTypes ? ReadonlyMap<string, CustomTypePropertyStorage<ValueType, Specifics>> : never) :

packages/typir/src/kinds/custom/custom-kind.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,14 @@ export class CustomKind<Properties extends CustomTypeProperties, Specifics exten
137137
// primitives
138138
else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') {
139139
return String(value);
140+
} else if (value === undefined) { // required for optional properties
141+
return 'undefined';
140142
}
141143
// composite with recursive object / index signature
142144
else if (typeof value === 'object' && value !== null) {
143145
return this.calculateIdentifierAll(value as CustomTypeInitialization<Properties, Specifics>);
144146
} else {
145-
throw new Error(`missing implementation for ${value}`);
147+
throw new Error(`missing implementation for '${value}'`);
146148
}
147149
}
148150
}

packages/typir/src/kinds/custom/custom-type.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,14 @@ export class CustomType<Properties extends CustomTypeProperties, Specifics exten
105105
// primitives
106106
else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') {
107107
return value as unknown as CustomTypePropertyStorage<T, Specifics>;
108+
} else if (value === undefined) { // required for optional properties
109+
return undefined as unknown as CustomTypePropertyStorage<T, Specifics>;
108110
}
109111
// composite with recursive object / index signature
110112
else if (typeof value === 'object' && value !== null) {
111113
return this.replaceAllProperties(value as CustomTypeInitialization<CustomTypeProperties, Specifics>, collectedReferences) as CustomTypePropertyStorage<T, Specifics>;
112114
} else {
113-
throw new Error(`missing implementation for ${value}`);
115+
throw new Error(`missing implementation for '${value}'`);
114116
}
115117
}
116118

@@ -167,7 +169,14 @@ export class CustomType<Properties extends CustomTypeProperties, Specifics exten
167169
return result;
168170
}
169171
protected analyzeTypeEqualityProblemsSingle<T extends CustomTypePropertyTypes>(value1: CustomTypePropertyStorage<T, Specifics>, value2: CustomTypePropertyStorage<T, Specifics>): TypirProblem[] {
170-
assertTrue(typeof value1 === typeof value2);
172+
if (typeof value1 !== typeof value2) {
173+
// this case might occur for optional properties, since `undefined` is a different TypeScript type than a non-undefined value
174+
return [<ValueConflict>{
175+
$problem: ValueConflict,
176+
firstValue: `'${String(value1)}' has the TypeScript type ${typeof value1}`,
177+
secondValue: `'${String(value2)}' has the TypeScript type ${typeof value2}`,
178+
}];
179+
}
171180
// a type is stored in a TypeReference!
172181
if (value1 instanceof TypeReference) {
173182
return checkTypes(value1.getType(), (value2 as TypeReference<Type, Specifics>).getType(), createTypeCheckStrategy('EQUAL_TYPE', this.kind.services), false);
@@ -226,6 +235,8 @@ export class CustomType<Properties extends CustomTypeProperties, Specifics exten
226235
// primitives
227236
else if (typeof value1 === 'string' || typeof value1 === 'number' || typeof value1 === 'boolean' || typeof value1 === 'bigint' || typeof value1 === 'symbol') {
228237
return checkValueForConflict(value1, value2, 'value');
238+
} else if (value1 === undefined) { // required for optional properties
239+
return checkValueForConflict(value1, value2, 'value');
229240
}
230241
// composite with recursive object / index signature
231242
else if (typeof value1 === 'object' && value1 !== null) {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/******************************************************************************
2+
* Copyright 2025 TypeFox GmbH
3+
* This program and the accompanying materials are made available under the
4+
* terms of the MIT License, which is available in the project root.
5+
******************************************************************************/
6+
7+
import { beforeEach, describe, expect, test } from 'vitest';
8+
import { CustomKind } from '../../../src/kinds/custom/custom-kind.js';
9+
import { TestingSpecifics, createTypirServicesForTesting } from '../../../src/test/predefined-language-nodes.js';
10+
import { TypirServices } from '../../../src/typir.js';
11+
12+
// These test cases test custom types with optional properties
13+
14+
describe('Optional custom properties', () => {
15+
type MyCustomType = {
16+
myNumber?: number;
17+
myString?: string;
18+
};
19+
20+
let typir: TypirServices<TestingSpecifics>;
21+
let customKind: CustomKind<MyCustomType, TestingSpecifics>;
22+
23+
beforeEach(() => {
24+
typir = createTypirServicesForTesting();
25+
26+
customKind = new CustomKind<MyCustomType, TestingSpecifics>(typir, {
27+
name: 'MyCustom1',
28+
calculateTypeName: properties => `Custom1-${properties.myNumber}-${properties.myString}`,
29+
});
30+
});
31+
32+
test('Specified non-undefined values', () => {
33+
const properties = customKind.create({ properties: { myNumber: 123, myString: 'hello' } }).finish().getTypeFinal()!.properties;
34+
expect(properties.myNumber).toBe(123);
35+
expect(properties.myString).toBe('hello');
36+
});
37+
38+
test('Skipped all values (implicit undefined)', () => {
39+
const properties = customKind.create({ properties: { /* empty */ } }).finish().getTypeFinal()!.properties;
40+
expect(properties.myNumber).toBe(undefined);
41+
expect(properties.myString).toBe(undefined);
42+
});
43+
44+
test('Used "undefined" as values (explicit undefined)', () => {
45+
const properties = customKind.create({ properties: { myNumber: undefined, myString: undefined } }).finish().getTypeFinal()!.properties;
46+
expect(properties.myNumber).toBe(undefined);
47+
expect(properties.myString).toBe(undefined);
48+
});
49+
50+
});
51+
52+
describe('Custom properties with "undefined" as type', () => {
53+
type MyCustomType = {
54+
myNumber: number | undefined;
55+
myString: string | undefined;
56+
};
57+
58+
let typir: TypirServices<TestingSpecifics>;
59+
let customKind: CustomKind<MyCustomType, TestingSpecifics>;
60+
61+
beforeEach(() => {
62+
typir = createTypirServicesForTesting();
63+
64+
customKind = new CustomKind<MyCustomType, TestingSpecifics>(typir, {
65+
name: 'MyCustom1',
66+
calculateTypeName: properties => `Custom1-${properties.myNumber}-${properties.myString}`,
67+
});
68+
});
69+
70+
test('Specified non-undefined values', () => {
71+
const properties = customKind.create({ properties: { myNumber: 123, myString: 'hello' } }).finish().getTypeFinal()!.properties;
72+
expect(properties.myNumber).toBe(123);
73+
expect(properties.myString).toBe('hello');
74+
});
75+
76+
test('Used "undefined" as values (explicit undefined)', () => {
77+
const properties = customKind.create({ properties: { myNumber: undefined, myString: undefined } }).finish().getTypeFinal()!.properties;
78+
expect(properties.myNumber).toBe(undefined);
79+
expect(properties.myString).toBe(undefined);
80+
});
81+
82+
});

0 commit comments

Comments
 (0)