If you had the capability to distinguish between id
s for Store
s and those for StoreOwner
s during compilation, it would simplify matters immensely. For instance, if all Store
ids started with "store_" and StoreOwner
ids began with "owner_", then you could leverage template literal types to maintain this differentiation:
type StoreId = `store_${string}`;
type StoreOwnerId = `owner_${string}`;
interface StoreOwner {
id: StoreOwnerId;
name: string;
store: Store;
}
interface Store {
id: StoreId;
name: string;
address: string;
}
The dataNormalized
table would then become an intersection of Record
object types, aligning StoreId
keys with Store
values, and StoreOwnerId
keys with StoreOwner
values:
declare const dataNomalized: Record<StoreId, Store> & Record<StoreOwnerId, StoreOwner>;
By doing so, your getEntity()
function could be implemented like this:
function getEntity<I extends StoreId | StoreOwnerId>(id: I) {
const result = dataNomalized[id];
if (!result) throw new Error("No entry found for ID '" + id + "'")
return result;
}
This approach ensures that both runtime and compile time behaviors will function seamlessly:
console.log(getEntity("store_b").address.toUpperCase());
console.log(getEntity("owner_a").store.address.toUpperCase());
getEntity("unknown_id") // Compiler error,
// 'string' is not assignable to '`store_${string}` | `owner_${string}`'
However, without the ability to distinguish between ids
for Store
s and StoreOwner
s at compile time, challenges arise. Both are considered as mere string
s by the compiler, leading to a lack of discrimination.
In such cases, one viable strategy is to introduce a classification system for primitive types, like string
, by assigning them distinct tags based on their usage:
type Id<K extends string> = string & { __type: K }
type StoreId = Id<"Store">;
type StoreOwnerId = Id<"StoreOwner">;
Consequently, a StoreId
theoretically represents a string
that also holds a __type
property denoting the type "Store". Similarly, a StoreOwnerId
serves the same purpose but with its __type
property set to "StoreOwner". While conceptually sound, this distinction does not bear relevance at runtime where only strings exist, making it more of a conceptual convenience than an enforced rule.
The interface declarations remain consistent:
interface StoreOwner {
id: StoreOwnerId;
name: string;
store: Store;
}
interface Store {
id: StoreId;
name: string;
address: string;
}
Undoubtedly, the type of dataNormalized
remains as
Record<StoreId, Store> & Record<StoreOwnerId, StoreOwner>
, necessitating manual assertion of key types since the compiler lacks the capability to verify them:
const owner = {
id: "x",
name: "John",
store: {
id: "y",
name: "John's Store",
address: "333 Elm St"
}
} as StoreOwner; // Type assertion
const dataNormalized = {
x: owner,
y: owner.store,
} as Record<StoreId, Store> & Record<StoreOwnerId, StoreOwner>; // Type assertion
While getEntity()
can still be realized similarly, direct usage proves challenging:
getEntity("x") // Compiler error!
getEntity("y") // Compiler error!
//Argument of type 'string' is not assignable to parameter of type 'StoreId | StoreOwnerId'.
Even though explicit assertions can be made regarding key types, there exists a risk of asserting incorrectly:
getEntity("x" as StoreId).address.toUpperCase(); // Compile-time pass but
// Runtime error: 💥 Accessing .address on undefined entity
To mitigate this uncertainty, implementing functions for runtime validation of keys before conversion to StoreId
or StoreOwnerId
can add a layer of safety. This involves a generalized custom type guard function alongside specific helpers for each id type:
function isValidId<K extends string>(input: string, type: K): input is Id<K> {
// Any necessary runtime validation goes here
if (!(input in dataNormalized)) return false;
const checkKey: string | undefined = ({ Store: "address", StoreOwner: "store" } as any)[type];
if (!checkKey) return false;
if (!(checkKey in (dataNormalized as any)[input])) return false;
return true;
}
function storeId(input: string): StoreId {
if (!isValidId(input, "Store")) throw new Error("Invalid store id given: '" + input + "'");
return input;
}
function storeOwnerId(input: string): StoreOwnerId {
if (!isValidId(input, "StoreOwner")) throw new Error("Invalid store owner id supplied: '" + input + "'");
return input;
}
With these validations in place, code execution becomes safer:
console.log(getEntity(storeId("y")).address.toUpperCase());
console.log(getEntity(storeOwnerId("x")).store.address.toUpperCase());
Yet, possible errors can still occur at runtime despite the added precautions:
getEntity(storeId("unknown_id")).address.toUpperCase(); // Invalid store id 'unknown_id'
Ultimately, while not optimal, employing branded primitives aids in maintaining clarity when compile-time distinctions are unachievable. The focus shifts from enforcing strict rules to enhancing semantic understanding, acknowledging the limitations encountered in bridging the gap between static and dynamic typing.
Link to Playground containing code