Entering key-value pairs into a dictionary to show correlation

I've been struggling to find a solution for what seems like a simple issue.

The problem lies in typing a dictionary with values of different types so that TypeScript can infer the type based on the key.

Here is the scenario:

type Id = string;

interface Shop {
  id: Id;
  a: ... // `a` is a property unique to Shop
}

interface ShopOwner {
  id: Id;
  b: ... // `b` is a property unique to ShopOwner
}

type Objects = {[key: Id]: Shop | ShopOwner }

// When trying to access a property `b` from a key known to return ShopOwner,
// TypeScript throws an error due to potential ambiguity between Shop and ShopOwner
const shopOwner = dictionary[key].b 

I can manually check the value's type before accessing b, but I suspect there might be a more elegant solution using generics. Something like:

type Id<T extends Shop | ShopOwner> = ?
type Objects<T extends Shop | ShopOwner> = {[key: Id<T>]: T}

How could such a mechanism be implemented?

Any help would be greatly appreciated!

Answer №1

If you had the capability to distinguish between ids for Stores and those for StoreOwners 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 Stores and StoreOwners at compile time, challenges arise. Both are considered as mere strings 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

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Retrieve the implementation of an interface method directly from the constructor of the class that implements it

I am looking to create a function that takes a string and another function as arguments and returns a string: interface Foo { ConditionalColor(color: string, condition: (arg: any) => boolean): string; } I attempted to pass the ConditionalColor metho ...

Leveraging Typescript's robust type system to develop highly specific filter functions

I'm attempting to utilize the robust TypeScript type system in order to construct a highly typed 'filter' function that works on a collection (not just a simple array). Below is an illustration of what I am striving for: type ClassNames = &a ...

Can the getState() method be utilized within a reducer function?

I have encountered an issue with my reducers. The login reducer is functioning properly, but when I added a logout reducer, it stopped working. export const rootReducer = combineReducers({ login: loginReducer, logout: logoutReducer }); export c ...

A versatile sorting algorithm

Currently, I am working on converting the material UI sorting feature into a generic type. This will enable me to utilize it across various tables. However, I have hit a roadblock in implementing the stableSort function, which relies on the getSorting func ...

Trouble with 'import type' declaration causing build issues in a Next.js project

Having trouble importing the Metadata type from the next module. The code snippet below is directly from the Next.js documentation. THE ISSUE client.js:1 ./app/layout.tsx:3:12 Syntax error: Unexpected token, expected "from" 1 | import React from 'r ...

Creating customized object mappings in Typescript

In my current Angular project, I am working on mapping the response from the following code snippet: return this.http.get(this.url) .toPromise() .then(response => response as IValueSetDictionary[]) .catch(this.handleError); The respon ...

What are the different ways to customize the appearance of embedded Power BI reports?

Recently, I developed a website that integrates PowerBI embedded features. For the mobile version of the site, I am working on adjusting the layout to center the reports with a margin-left style. Below are the configuration parameters I have set up: set ...

How to Publish an Angular 8 Application on Github Pages using ngh

Currently in my angular 8 project, I am encountering the following issue while running the command: ole@mkt:~/test$ ngh index.html could not be copied to 404.html. This does not look like an angular-cli project?! (Hint: are you sure that you h ...

Guiding you on exporting a Typescript class with parameters in Node.js

Trying to find the Typescript equivalent of require('mytypescriptfile')(optionsObject); This is the TS code provided: export class Animal { name: string; public bark(): string { return "bark " + this.name; } constructor(color:string) ...

Error message: `Socket.io-client - Invalid TypeError: Expected a function for socket_io_client_1.default`

I have successfully installed socket.io-client in my Angular 5.2 application, but after trying to connect (which has worked flawlessly in the past), I am encountering a strange error. TypeError: socket_io_client_1.default is not a function at new Auth ...

Running Jasmine asynchronously in a SystemJS and TypeScript setup

I am currently executing Jasmine tests within a SystemJS and Typescript environment (essentially a plunk setup that is designed to be an Angular 2 testing platform). Jasmine is being deliberately utilized as a global library, rather than being imported vi ...

Displaying data from an Angular subscription in a user interface form

I am attempting to transfer these item details to a form, but I keep encountering undefined values for this.itemDetails.item1Qty, etc. My goal is to display them in the Form UI. this.wareHouseGroup = this.formBuilder.group({ id: this.formBuilder.contr ...

Extending the Model class in TypeScript with Sequelize

Currently, I am tackling a legacy project with the goal of transitioning it to Typescript. The project contains models that are structured as shown below: import Sequelize from "sequelize"; class MyModel extends Sequelize.Model { public static init(seq ...

TypeScript Interfaces: A Guide to Defining and

In my angular2 application, I have created interfaces for various components. One of these interfaces is specifically for products that are fetched from a remote API. Here is the interface: export interface Product { id: number; name: string; } ...

What could be causing my controller method in TypeScript to throw an error message unexpectedly?

Hey there. I'm diving into TypeScript and currently working on converting an Express backend to TS. Everything was smooth sailing until I encountered some unexpected issues. Specifically, the lines const hasVoted = poll.votedBy.some((voter): boolean = ...

What is the approach to forming a Promise in TypeScript by employing a union type?

Thank you in advance for your help. I am new to TypeScript and I have encountered an issue with a piece of code. I am attempting to wrap a union type into a Promise and return it, but I am unsure how to do it correctly. export interface Bar { foo: number ...

Combining Vue with Typescript and rollup for a powerful development stack

Currently, I am in the process of bundling a Vue component library using TypeScript and vue-property-decorator. The library consists of multiple Vue components and a plugin class imported from a separate file: import FormularioForm from '@/FormularioF ...

Tips for establishing communication between a server-side and client-side component in Next.js

I am facing a challenge in creating an interactive component for language switching on my website and storing the selected language in cookies. The issue arises from the fact that Next.js does not support reactive hooks for server-side components, which ar ...

The validator function in FormArray is missing and causing a TypeError

I seem to be encountering an error specifically when the control is placed within a formArray. The issue arises with a mat-select element used for selecting days of the week, leading to the following error message: What might I be doing incorrectly to tri ...

The NUXT project encounters issues when trying to compile

I am currently working on an admin panel using the nuxt + nest stack. I am utilizing a template provided at this link: https://github.com/stephsalou/nuxt-nest-template While in development mode, the project starts up without any issues. However, when I ...