DEV Community

Acid Coder
Acid Coder

Posted on

Typescript Nominal Type: The Right Way

In case you don't know what RighFminal typing

Typescript is a structural typing language, but it is possible to mimic the behavior of nominal typing language, very closely.

We start with the easiest method:



type AB = { a:number, b:number, x:never}
type BA = { a:number, b:number, y:never}

const ab:AB = {a:1, b:2}
const ba:BA = ab // error !!


Enter fullscreen mode Exit fullscreen mode

Image description

playground

It works

BUT THIS IS NOT GOOD ENOUGH !!

Today we are going to learn how to create a proper nominal type in Typescript

Let us review why the first method is not good enough.

First, it uses type assertion. Avoid type assertion if possible, personally I only assert type when I am creating wrappers.

Second, users can access the extra property: brand via static typing. In this case, our brand is ab.x and ba.y.

As a craftsman, we don't want that to happen

Solution



declare class AB_Class { 
    protected x?:never
}

declare class BA_Class { 
    protected x?:never
}

interface AB extends AB_Class{
    a:number, b:number
}

interface BA extends BA_Class{
    a:number, b:number
}

const ab:AB = {a:1, b:2} 
const ba:BA = ab // error !!


Enter fullscreen mode Exit fullscreen mode

Image description

playground

The key is to use class and declare optional protected brands with never as type.

The brands name don't have to be the same name, you can assign different names.

  1. no type assertion
  2. users cannot access brand

now, you may ask, why protected, why not private?

This is because, take this example



declare class AB_Class { 
    private x?:never
}


Enter fullscreen mode Exit fullscreen mode

after tsc compilation, the output is



declare class AB_Class { 
    private x?
}


Enter fullscreen mode Exit fullscreen mode

do you notice the difference?

Typescript strips away the type of private and fails type assertion

so please stick with protected

Top comments (10)

Collapse
 
ivankleshnin profile image
Ivan Kleshnin • Edited

Does not look like a proper solution:

class Thing {
  protected x?: never
}

type Test = boolean | Thing

let t1: Test = new Thing() // OK: allowed
let t2: Test = {}          // BAD: allowed
let t3: Test = 3424234     // OK: disallowed
let t4: Test = "3424"      // OK: disallowed

console.log(Object.keys(t1)) // BAD: x affects Object.keys / for-in loops
console.log(t2)
console.log(t3)
console.log(t4)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tylim88 profile image
Acid Coder

not sure i understand this

let t2: Test = {} is expected because x is supposed to be not exist

Collapse
 
ivankleshnin profile image
Ivan Kleshnin • Edited

Do this instead:

const brand = Symbol("brand")

class Thing {
  protected readonly [brand]: undefined
}
Enter fullscreen mode Exit fullscreen mode

Symbols are enumerable by default but Object.keys and for-in work only with string props.
So it fixes all the above issues.

If you also want to prevent Object.assign copies, add this:

constructor() {
  Object.defineProperty(this, brand, {enumerable: false, configurable: false, writable: false})
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dphuang2 profile image
dphuang2

Have you looked at zod brand?

github.com/colinhacks/zod#brand

Collapse
 
tylim88 profile image
Acid Coder • Edited

I think zod brand is a very smart solution

but it has 2 problems

import { z, BRAND } from "zod";
const Cat = z.object({ name: z.string() }).brand<"Cat">();
type Cat = z.infer<typeof Cat>;

const cat = Cat.parse({ name: "simba" }); // or const cat = { name: "simba" } as Cat;
const catBrand = cat[BRAND];
type U = typeof catBrand; // { Cat: true }
console.log(catBrand); // undefined
Enter fullscreen mode Exit fullscreen mode
  1. still can access cat[BRAND]
  2. type of cat[BRAND] and value of cat[BRAND] mismatched

1 is unsatisfying but overall is not a problem, because nobody is going to do that
2 is an easy fix, just add optional modifier, although it still mislead some to believe it may return { Cat: true }

overall I think zod solution is practically perfect

Collapse
 
tylim88 profile image
Acid Coder • Edited

update:

I missed one point:

to assign Cat type to a variable, you either do

const cat = { name: "simba" } as Cat;
Enter fullscreen mode Exit fullscreen mode

since we want to avoid type assertion if possible

then we are left with:

const cat = Cat.parse({ name: "simba" });
Enter fullscreen mode Exit fullscreen mode

which is slightly unintuitive

ideally, we want

const cat: Cat = { name: "simba" }
Enter fullscreen mode Exit fullscreen mode

but this will error using zod method because it is missing symbol properties

Thread Thread
 
peerreynders profile image
peerreynders • Edited

In general TypeScript requires a runtime assertion in the absence of a compile time type assertion.

// Don't export this from the module
declare const validCat: unique symbol;

type Cat = {
  name: string;
  [validCat]: true;
};

function assertValidCat(
  maybeCat: Record<PropertyKey, unknown>
): asserts maybeCat is Cat {
  const entries = Object.entries(maybeCat);
  if (
    entries.length === 1 &&
    entries[0][0] === 'name' &&
    entries[0][1] === 'simba'
  )
    return;

  throw new Error('Not a Cat');
}

// outside of module

const catMaybe = { name: 'simba' }; // const catMaybe: { name: string }

assertValidCat(catMaybe);

const catDefinitely = catMaybe; // const catDefinitely: Cat, const catMaybe: Cat
console.log(Object.keys(catDefinitely).length); // 1
Enter fullscreen mode Exit fullscreen mode

Playground

You can even do this to primitive types

type Cat = string & {
  [validCat]: true;
};
Enter fullscreen mode Exit fullscreen mode

Assertion Functions

Source

A type predicate can be used to a similar end.


In a sense the fact that

const ab:AB = {a:1, b:2}; 
Enter fullscreen mode Exit fullscreen mode

even works is no better than

const ab = {a:1, b:2} as AB;
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
tylim88 profile image
Acid Coder • Edited

avoid type assertion because it is prone to error

Image description

playground

Thread Thread
 
peerreynders profile image
peerreynders

You've missed the point.

Typescript's assignability has edge cases that may surprise some people, perhaps the most infamous one is that assignments are not subject to excess property checks.

So it could be argued:

type AB = { a: number; b: number; x: never };

// For nominal typing this assignment should
// ideally error as the literal isn't branded to begin with.
const ab: AB = { a: 1, b: 2 };
Enter fullscreen mode Exit fullscreen mode

Compare that to

declare const validAB: unique symbol;
declare const validBA: unique symbol;

type AB = {
  a: number;
  b: number;
  [validAB]: true;
};

type BA = {
  a: number;
  b: number;
  [validBA]: true;
};

// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
function assertAB(
  maybeAB: Record<PropertyKey, unknown>
): asserts maybeAB is AB {
  const keys = Object.keys(maybeAB);
  if (
    keys.length === 2 &&
    typeof maybeAB['a'] === 'number' &&
    typeof maybeAB['b'] === 'number'
  )
    return;

  throw new Error('Not an AB type');
}

// const ab: AB = {a:1, b:2}
// error: Property '[validAB]' is missing in type '{ a: number; b: number; }' but required in type 'AB'.(2741)

// const ba: BA = {a:1, b:2}
// error: Property '[validBA]' is missing in type '{ a: number; b: number; }' but required in type 'BA'.(2741)

const ab = { a: 1, b: 2 };
// unbranded:  const ab: { a: number; b: number; }

assertAB(ab);
// now branded: const ab: AB
// … and IntelliSense only shows properties `a` and `b`

// const ba: BA = ab;
// error: Property '[validBA]' is missing in type 'AB' but required in type 'BA'.(2741)

console.log(ab);
Enter fullscreen mode Exit fullscreen mode

playground

Thread Thread
 
tylim88 profile image
Acid Coder • Edited

personally i avoid type predicate, because there is nothing to prevent what could goes wrong in the assertion, for example, i could change something in it(as shown in image) and it will still work, but it will give wrong result because the code is wrong

Image description

for excess property check problem, i believe it can be solved on type level, without relying on runtime assertion

it was a problem that haunted me, but it is not a concern to me anymore, so i believe i solved it at some point iirc, (or i just gave up, i will try to confirm it again)

update: this is the type level solution for excess property check, without relying on runtime assertion

Image description

playground
playground(simplified)