Specialized Character Formats in TypeScript

In my quest to enhance the clarity in distinguishing different types of strings within my program - such as absolute paths and relative paths, I am seeking a solution that ensures functions can only take or return specific types without errors.

Consider the following scenario:

function makeAbsolute(path: RelativePath): AbsolutePath {
}

In the above example, both AbsolutePath and RelativePath are essentially string types. I have experimented with type aliases but found they do not truly create a new type. Similarly, interfaces like:

interface AbsolutePath extends String { }
interface RelativePath extends String { }

Unfortunately, these interfaces are compatible which allows for potential mix-ups without any warning from the compiler. It seems like the only way to achieve this is by either adding a property to the interface to ensure incompatibility (by actually incorporating that property into the string itself or casting around it), or utilizing a wrapper class. Are there any alternative approaches I could explore?

Answer №1

There are numerous methods to achieve this task, all of which involve tagging the target type using intersections.

Utilizing Enum Tags

We can exploit the fact that TypeScript has one nominal type - the Enum type to differentiate structurally identical types:

An enum type serves as a distinct subtype of the Number primitive type

What does this signify?

Interfaces and classes are assessed structurally

interface First {}
interface Second {}

var x: First;
var y: Second;
x = y; // Compiles because First and Second are structurally equivalent

Enums differ based on their "identity" (e.g. they are nominatively typed)

const enum First {}
const enum Second {}

var x: First;
var y: Second;
x = y;  // Compilation error: Type 'Second' is not assignable to type 'First'.

We can utilize the nominal typing of Enum to tag or brand our structural types in two ways:

Tagging Types with Enum Types

By making use of intersection types and type aliases in TypeScript, we can label any type with an enum and designate it as a new type. We can then cast any instance of the base type to the tagged type effortlessly:

const enum MyTag {}
type SpecialString = string & MyTag;
var x = 'I am special' as SpecialString;
// The type of x is `string & MyTag`

This approach allows us to categorize strings as either Relative or Absolute paths (not applicable for tagging a number - refer to the second option for such cases):

declare module Path {
  export const enum Relative {}
  export const enum Absolute {}
}

type RelativePath = string & Path.Relative;
type AbsolutePath = string & Path.Absolute;
type Path = RelativePath | AbsolutePath

Hence, by casting, we can label any string instance as any form of Path:

var path = 'thing/here' as Path;
var absolutePath = '/really/rooted' as AbsolutePath;

However, there is no validation during casting, so it's feasible to do:

var assertedAbsolute = 'really/relative' as AbsolutePath;
// compiles without issue, fails at runtime somewhere else

To address this concern, control-flow based type checks can ensure casting only if a test passes (at run time):

function isRelative(path: String): path is RelativePath {
  return path.substr(0, 1) !== '/';
}

function isAbsolute(path: String): path is AbsolutePath {
  return !isRelative(path);
}

These functions can be utilized to guarantee handling correct types without any run-time errors:

var path = 'thing/here' as Path;
if (isRelative(path)) {
  // path's type is now string & Relative
  withRelativePath(path);
} else {
  // path's type is now string & Absolute
  withAbsolutePath(path);
}

Structural Branding of Interfaces / Classes Using Generics

Regrettably, we cannot tag subtypes like Weight or Velocity since TypeScript simplifies number & SomeEnum to just number. Instead, generics and a field can be used to brand a class or interface and attain comparable nominal-type behavior. This method resembles @JohnWhite's suggestion with private name, but avoids name clashes as long as the generic is an enum:

/**
 * Nominal typing for any TypeScript interface or class.
 *
 * If T is an enum type, any type embracing this interface
 * will only correspond with other types labeled with the same
 * enum type.
 */
interface Nominal<T> { 'nominal structural brand': T }

// Alternatively, you can employ an abstract class
// By having the type argument `T extends string`
// rather than `T /* must be enum */`
// collisions can be avoided if you choose a matching string as someone else
abstract class As<T extends string> {
  private _nominativeBrand: T;
}

declare module Path {
  export const enum Relative {}
  export const enum Absolute {}
}
type BasePath<T> = Nominal<T> & string
type RelativePath = BasePath<Path.Relative>
type AbsolutePath = BasePath<Path.Absolute>
type Path = RelativePath | AbsolutePath

// Indicate that this string is some kind of Path
// (Equivalent to using
// var path = 'thing/here' as Path
// which is the function's primary purpose).
function toPath(path: string): Path {
  return path as Path;
}

The "constructor" needs to create instances of branded types from the base types:

var path = toPath('thing/here');
// alternatively, a type cast also suffices
var path = 'thing/here' as Path

Furthermore, control-flow based types and functions can enhance compile-time safety:

if (isRelative(path)) {
  withRelativePath(path);
} else {
  withAbsolutePath(path);
}

Moreover, this technique applies to number subtypes as well:

declare module Dates {
  export const enum Year {}
  export const enum Month {}
  export const enum Day {}
}

type DatePart<T> = Nominal<T> & number
type Year = DatePart<Dates.Year>
type Month = DatePart<Dates.Month>
type Day = DatePart<Dates.Day>

var ageInYears = 30 as Year;
var ageInDays: Day;
ageInDays = ageInYears;
// Compilation error:
// Type 'Nominal<Month> & number' is not assignable to type 'Nominal<Year> & number'.

Adapted from https://github.com/Microsoft/TypeScript/issues/185#issuecomment-125988288

Answer №2

class PathStrategy extends String {
    public static createFromUrl(url: string, isRelative: boolean): PathStrategy {
        if (isRelative) {
            // validate if 'url' is indeed a relative path
            // for example, if it does not begin with '/'
            // ...
            return url as any;
        } else {
            // validate if 'url' is indeed an absolute path
            // for example, if it begins with '/'
            // ...
            return url as any;
        }
    }

    private __pathTypeFlag;
}
var path1 = PathStrategy.createFromUrl("relative/path", true);
var path2 = PathStrategy.createFromUrl("/absolute/path", false);

// Compile error: type 'PathStrategy' is not assignable to type 'string'
path1 = path2;

console.log(typeof path1); // "string"
console.log(typeof path2); // "string"
console.log(path1.toUpperCase()); // "RELATIVE/PATH"

This may seem unconventional, but it serves its purpose effectively.

Since their creation is controlled this way, instances of `PathStrategy` are:

  • considered incompatible by TypeScript compiler due to private property
  • treated as strings by the TypeScript compiler, allowing string functions to be called
  • actually functioning as real strings at runtime, supporting the expected string operations

This approach can be seen as simulating inheritance without introducing new public members or methods, ensuring consistent behavior between compile time and runtime execution.

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

The issue arising from utilizing the export class function in Angular 8

Hey there! I'm working on an Angular application and just getting started with it. My current version is Angular 8, and I've encountered an issue that I need help with. In my project, I have a shared model named "Client" which is defined in a fi ...

Concealing a VueJs component on specific pages

How can I hide certain components (AppBar & NavigationDrawer) on specific routes in my App.vue, such as /login? I tried including the following code in my NavigationDrawer.vue file, but it disables the component on all routes: <v-navigation-drawer ...

TypeScript's TypeGuard wandering aimlessly within the enumerator

I'm puzzled by the fact that filter.formatter (in the penultimate line) is showing as undefined even though I have already confirmed its existence: type Filter = { formatter?: { index: number, func: (value: string) => void ...

"Fixing the cubic-bezier for the exiting animation ends up causing issues with the entering

Trying to implement a collapsing list animation using React/Joy-UI. Below is the Transition element code snippet: <Transition nodeRef={nodeRef} in={browseOpen} timeout={1000}> {(state: string) => (<List aria-labelledby="nav-list-bro ...

Jest does not support the processing of import statements in typescript

I am attempting to execute a simple test. The source code is located in src/index.ts and contains the following: const sum = (a, b) => {return a+b} export default sum The test file is located in tests/index.test.ts with this code: impor ...

An error occured: Unable to access the 'taxTypeId' property since it is undefined. This issue is found in the code of the View_FullEditTaxComponent_0, specifically in the update

I am encountering an issue with a details form that is supposed to load the details of a selected record from a List Form. Although the details are displayed correctly, there is an error displayed on the console which ultimately crashes the application. T ...

Different States for a single element within the React + Redux Template in Visual Studio

I have come across an issue while using the Visual Studio 2017 React + Redux template. I followed the setup for stores as per their guidelines and everything was working fine so far. However, now I need to provide a component access to multiple states. The ...

The function in Angular 5/Typescript disappears when attempting to call it from within another function

After importing D3 into my component, I encounter an issue when trying to assign a layout to the D3.layout property. Strangely, although the layout property is present in the console output of my D3 object, it seems to be unknown when I attempt to call i ...

Encountered an issue while trying to install the package '@angular/cli'

Encountered errors while attempting to install @angular/cli using npm install -g @angular/cli. The node and npm versions on my system are as follows: C:\WINDOWS\system32>node -v v 12.4.0 C:\WINDOWS\system32>npm -v 'C ...

After upgrading to version 4.0.0 of typescript-eslint/parser, why is eslint having trouble recognizing JSX or certain react @types as undefined?"

In a large project built with ReactJs, the eslint rules are based on this specific eslint configuration: const DONT_WARN_CI = process.env.NODE_ENV === 'production' ? 0 : 1 module.exports = { ... After upgrading the library "@typescript-es ...

Converting a JavaScript function to TypeScript with class-like variables inside: a step-by-step guide

During the process of converting a codebase to TypeScript, I encountered something unfamiliar. In particular, there are two functions with what appear to be class-like variables within them. The following function is one that caught my attention: const wai ...

Jest Test - Uncaught TypeError: Unable to create range using document.createRange

my unique test import VueI18n from 'vue-i18n' import Vuex from "vuex" import iView from 'view-design' import {mount,createLocalVue} from '@vue/test-utils' // @ts-ignore import FormAccountName from '@/views/forms/FormAcco ...

Transforming a "singular or multiple" array into an array of arrays using TypeScript

What is causing the compilation error in the following code snippet, and how can it be resolved: function f(x: string[] | string[][]): string[][] { return Array.isArray(x[0]) ? x : [x]; } Upon inspection, it appears that the return value will constantly ...

Using the expect statement within a Protractor if-else block

My script involves an if-else condition to compare expected and actual values. If they do not match, it should go to the else block and print "StepFailed". However, it always executes the if block and the output is "step passed" even when expected does not ...

Error encountered during Jasmine unit testing for the ng-redux @select directive

Here is a snippet from my component.ts file: import { Component, OnInit } from '@angular/core'; import { select } from 'ng2-redux'; import { Observable } from 'rxjs/Observable'; import { PersonalDetailsComponent } from ' ...

Stopping HTTP client calls in OnDestroy hook of an Angular Service

Is it possible to automatically unsubscribe from an http call in an Angular service using the ngOnDestroy hook? Just to note, I am already familiar with using the rxjs 'take' operator or manually unsubscribing from the service within the compone ...

Unable to retrieve values using any = {} in TypeScript Angular 8

import { Component, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { enableProdMode } from '@angular/core'; enableProdMode(); @Component({ selector: 'app-home', templat ...

Locating the source and reason behind the [object ErrorEvent] being triggered

I'm facing an issue where one of my tests is failing and the log is not providing any useful information, apart from indicating which test failed... LoginComponent should display username & password error message and not call login when passed no ...

Utilizing the 'create' function in sqlite each time I need to run a query

I've been diving into SQLite within the Ionic framework and have pieced together some code based on examples I've encountered. import { Component } from '@angular/core'; import { IonicPage, NavController, NavParams } from 'ionic-a ...

What is the best way to clear a form in a Next.js 13.4 component following a server action?

Currently, I am working on a component using next.js 13.4, typescript, and resend functionality. My code is functioning properly without clearing data from inputs, as it uses the "action" attribute which is commented out. However, I started incorporating ...