TypeScript's type system works on the premise that values must have a fixed type that remains constant over time. For example, if you declare let a: string = "hey";
, you are informing the compiler that a
is and will always be of type string
. Attempting to later assign a = 4;
will result in a compile-time error. Similarly, when you define let b = "hey";
without explicitly specifying the type, the compiler will infer it as string
, hence writing b = 4;
afterwards will trigger an error. To allow a variable to hold different types at different times, you should annotate it as string | number
upon declaration, like so: let c: string | number = "hey";
. Subsequently, assigning c = 4;
would be acceptable.
In TypeScript, when working with objects, you need to predefine the property types during declaration. Therefore, the following code snippet will produce errors:
let o = {}; // type {}
o.a = new A('a'); // error, {} has no "a" property
o.b = new A(1); // error, {} has no "b" property
On the other hand, the subsequent code block will execute successfully:
let o2 = { a: new A('a'), b: new A(1) }; // okay
// let o2: { a: A<string>; b: A<number>; }
An advantage of TypeScript is its control flow type analysis feature, where the compiler temporarily narrows the type of a value based on specific conditions. For instance, after initializing let c: string | number = "hey"
, calling c.toUpperCase()
won't raise an error because the type of c
transitions from string | number
to
string</code. However, without this analysis, you'd require a type assertion such as <code>(c as string).toUpperCase()
. It's crucial to note that narrowing a value's type via control flow type analysis can only restrict it to a subtype or revert back to the original annotated/inferred type upon reassignment. While this technique enables adding properties to objects dynamically, it doesn't facilitate altering existing property types or deleting properties outright. If deletion is necessary, newly added properties should be optional.
In TypeScript 3.7, assertion functions were introduced to customize the narrowing process for function arguments based on control flow logic. As demonstrated through the custom setProp
function, which appends optional properties to an object, you have flexibility in extending objects while maintaining type safety:
function setProp<O extends object, K extends PropertyKey, V>(
obj: Partial<O>,
key: Exclude<K, keyof O>, value: V
): asserts obj is Partial<O & Record<K, V>> {
(obj as any)[key] = value as any;
}
The usage example clarifies how map
can be progressively updated using setProp
to insure type consistency:
const map = {}
map.a = new A('a'); // error, cannot perform this
// but the following statement is acceptable:
setProp(map, 'a', new A('a'));
// once set, you can reallocate it to the same type
map.a = new A('b');
setProp(map, 'b', new A(1));
setProp(map, 'c', "a string");
To access and delete properties, typical property access methods work provided you check for undefined
beforehand due to their optional nature:
map.a && console.log(map.a.value); // b
map.b && console.log(map.b.value); // 1
delete map.b; // okay
map.c && console.log(map.c.toUpperCase()); // A STRING
Ultimately, defining types upfront during object declaration rather than relying solely on control-flow type narrowing enhances overall robustness. Although convenient, control-flow-based narrowing should complement explicit annotations for optimal code clarity and reliability.
If opting for a Map
over a standard object, consider crafting customized typings for Map
to accommodate varying value types more efficiently. Check out the Playground Link mentioned below for a comprehensive implementation along with the aforementioned code segments.
I trust these insights provide valuable guidance. Best of luck!
Playground Link