Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nominal typing support (nonsensible intersection error) #911

Closed
sergio-milu opened this issue Dec 26, 2023 · 43 comments
Closed

Nominal typing support (nonsensible intersection error) #911

sergio-milu opened this issue Dec 26, 2023 · 43 comments
Assignees
Labels
good first issue Good for newcomers invalid This doesn't seem right question Further information is requested wontfix This will not be worked on

Comments

@sergio-milu
Copy link

sergio-milu commented Dec 26, 2023

Question

Hi all, I'm trying to migrate from class-validator/class-transformer to typia and nestia, but I'm facing some issue that don't know how yo fix, or wether is expected or not.

I have an auxiliar type to create "kinf of" nominal typing

declare type Nominal<Type, Identifier> = Type & {
  readonly [__nominal__type]: Identifier;
};

export type UserID = Nominal<string, 'UserID'>;

In my DTO i use it like this

export interface TestDTO {
  @ApiProperty({ type: String, format: 'uuid' })
  @IsUUID()
  userId: userId;
}

when I remove old anotations and try to use typia and nestia I'm seeing the following error (when running a typescript check)

 error TS(@nestia.core.TypedBody): unsupported type detected
- CreateRecommendationDTO.memberId: string & __type
  - nonsensible intersection

I found this PR that seems that this should be supported.

any clue why this is happening ?

thanks

@sergio-milu sergio-milu changed the title Nominal typing support Nominal typing support (nonsensible intersection error) Dec 26, 2023
@samchon
Copy link
Owner

samchon commented Dec 30, 2023

What the __nominal__type type is?

@samchon samchon self-assigned this Dec 30, 2023
@samchon samchon added the question Further information is requested label Dec 30, 2023
@sergio-milu
Copy link
Author

What the __nominal__type type is?

oh yes sorry, forgot to copy it

declare const __nominal__type: unique symbol;

but this doesnt seem to be a issue only with this example, tested with this code and same issue

@samchon
Copy link
Owner

samchon commented Jan 3, 2024

It seems actually nonsense type. Is there any reason why adding the __nominal_type__ in the DTO?

Is it required for client develoers?

@samchon samchon added the invalid This doesn't seem right label Jan 3, 2024
@sergio-milu
Copy link
Author

It seems actually nonsense type. Is there any reason why adding the __nominal_type__ in the DTO?

Is it required for client develoers?

nop, is client-side we translate those types to just string , but in our whole backend codebase we use this 'branded' types, it's a convenience, otherwise I'll need to cast those string from DTO to internal user cases.

Also found this , that is the same case that seems resolved, but got same error.

that nominal type works in TS, I took it from this issue

@samchon
Copy link
Owner

samchon commented Jan 3, 2024

To accomplish your requirement, we need to make a logic that determining whether intersection type is one of nonsensible, or especially allowed. As you know, the type number & object is basically nonsensible due to number type cannot be object type, and object type neither can't be the number type.

Previous PR (#657) allowed only when the object type has only optionial properties. By the way, you wanna make an intersection type that object side has the required property. In that case, I don't know how to distinguish between insensible or intended. If do you have any special idea, please inform me. If not, recommend to change your object type to be optional.

@samchon samchon added the good first issue Good for newcomers label Jan 3, 2024
@sergio-milu
Copy link
Author

I changed my 'branded' types to this

export enum TestType {
  __brand = 'Test',
}

export type TestID = string & TestType;

and seems that this works, thanks!

@sergio-milu
Copy link
Author

I changed my 'branded' types to this

export enum TestType {
  __brand = 'Test',
}

export type TestID = string & TestType;

and seems that this works, thanks!

I've realized another issues arised with this change, and is that the validator fails

 err: {
      "type": "BadRequestException",
      "message": "Request query data is not following the promised type.",
      "stack":
          BadRequestException: Request query data is not following the promised type.
              at /Users/sergiofernandez/Repositories/milu-web/node_modules/@nestia/core/src/decorators/internal/validate_request_query.ts:25:16
              at TypedQuery (/Users/sergiofernandez/Repositories/milu-web/node_modules/@nestia/core/src/decorators/TypedQuery.ts:59:31)
              at /Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/helpers/context-utils.js:43:28
              at resolveParamValue (/Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/router/router-execution-context.js:145:31)
              at Array.map (<anonymous>)
              at pipesFn (/Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/router/router-execution-context.js:150:45)
              at /Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/router/router-execution-context.js:37:36
              at InterceptorsConsumer.transformDeferred (/Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/interceptors/interceptors-consumer.js:31:33)
              at /Users/sergiofernandez/Repositories/milu-web/node_modules/@nestjs/core/interceptors/interceptors-consumer.js:18:86
              at AsyncResource.runInAsyncScope (node:async_hooks:203:9)
      "response": {
        "type": "Object",
        "message": "Request query data is not following the promised type.",
        "stack":

        "path": "$input.memberId",
        "reason": "Error on typia.http.assertQuery(): invalid type on $input.memberId, expect to be \"Member\"",
        "expected": "\"Member\"",
        "value": "d7652fd0-eb23-428a-9c13-ff8a8d9c3495"
      },

TSC and nestia's swagger validator is working right and I have the swagger definition working and all TS types, do you know how can handle this error? @samchon

@samchon
Copy link
Owner

samchon commented Jan 11, 2024

Make the property type to be optional. It is the only one way at now.

If you do not want it, consider and suggest me how to distinguish whether valid or not.

@sergio-milu
Copy link
Author

sergio-milu commented Jan 11, 2024

Make the property type to be optional. It is the only one way at now.

If you do not want it, consider and suggest me how to distinguish whether valid or not.

cant do that, cause if optional TS wont plain and will accept any string example

I was thinking in a way to fix this, and came up with an idea

typia could expose a new type tags.Nominal<'MyString> or tags.Branded<'MyString> and internally this type will be

 type tags.Branded<Identifier extends string> = {
  __INTERNAL_TYPIA_BRAND: Identifier;
};

then we can use it the same way we use typia's validatos string & tags.Nomial<'UserID'>

so, with that is possible to do something similar to this PR, detect that special type and ignore it (same way you are ignoring optional properties in object and letting only the sting or number), WDYT?

@samchon
Copy link
Owner

samchon commented Jan 15, 2024

I think it would better to change your type. Your case seems too domestic and occurs hard coding.

@sergio-milu
Copy link
Author

I think it would better to change your type. Your case seems too domestic and occurs hard coding.

thing is that if I change my type I'll break the whole type checking that I have, because I have several Ids (UserID, MemberID, AdminID) and if I use optional object, that is the same as using string for TS (I doesnt make sense to do string & {prop?: number} as that is the same as just string)

so IMHO this is a stopper for everyone using this nominal/branded types

@samchon
Copy link
Owner

samchon commented Jan 15, 2024

type MyNumber = number & {
  __brand__?: Something;
}

If not like above, then no way to validate.

In actually, below type becomes compile error in tsc.

No way for typia to support that tsc is prohibiting.

const value: number & { nested: Record<string, string> } = 3;

@samchon samchon closed this as not planned Won't fix, can't repro, duplicate, stale Jan 15, 2024
@sergio-milu
Copy link
Author

sergio-milu commented Jan 15, 2024

type MyNumber = number & {
  __brand__?: Something;
}

If not like above, then no way to validate.

In actually, below type becomes compile error in tsc.

No way for typia to support that tsc is prohibiting.

const value: number & { nested: Record<string, string> } = 3;

dont think this is 'too domestic' this is exposed in really well-known libs
type-fest
zod

long thread talking about this -> https://twitter.com/mattpocockuk/status/1625173884885401600

would you be open to a PR or some help? we dont want to lose this type-safety in our codebase

@samchon
Copy link
Owner

samchon commented Jan 17, 2024

Then make the brand type's property to be optional.

typia cannot support that TypeScript compiler occurs error case.

@samchon
Copy link
Owner

samchon commented Jan 20, 2024

image

If TypeScript core team makes it not be compile error, I'll consider your opinion.

As you know, typia is using the pure Typescript type directly, so no way to supporting prohibited feature of TypeScript.

@samchon samchon added the wontfix This will not be worked on label Jan 20, 2024
@bradleat
Copy link

bradleat commented May 1, 2024

I'm so confused why you can't just detect the interesction of a string and another type and then only check for it being a string. Nobody wants to do

const branded: Branded = {something: "nothing");

They want to do

const branded: Branded = "string value"

There are tons of things that the typescript community does that you refuse to support that make this library unusable. If you'd like, I can help you fix it. I'd rather use this than Zod.

@bradleat
Copy link

bradleat commented May 1, 2024

Like why not detect the string & {} pattern here:

// ESCAPE WHEN ONLY CONSTANT TYPES EXIST
    if (
      atomics.size + constants.length + arrays.size > 1 ||
      individuals.length + objects.length !== children.length
    ) {
      errors.push({
        name: children.map((c) => c.getName()).join(" & "),
        explore: { ...explore },
        messages: ["nonsensible intersection"],
      });
      return true;
    }

and just allow it?

Branded types are very popular in typescript. I really don't understand the problem you are making this out to be.

@matAtWork
Copy link

@samchon , I think you're missing the point of the "Branding" - a technique that is used within the typescript compiler itself.

Consider the types:

export type Brand<K, T> = K & { __brand: T };
export type Weeks = Brand<number, 'week'>;
export type Days = Brand<number, 'day'>;

let x = 2 as Weeks;
let y = 14 as Days;

function onVacation(n: Weeks) { /* ... */ }

onVacation(x); // Good
onVacation(y); // Error!

The point here is that you can initialise with a type assertion, and have some confidence that you can't pass the wrong scalar type to a function that expects a different one, BECAUSE TS shows an error, not because it does show an error.

This is the only practical and recommened (as in used by TS, for example here https://github.com/microsoft/TypeScript/blob/99878128f032786bd3ad1295402a04ca7002eeb2/src/compiler/builder.ts#L1073) to subclass primitive types. It is also used to subclass object types, but this is less of an issue as objects can have excess properties in any case.

One possible solution to this would be to allow users to exclude the brand field names from typia typechecking, so for example I could use:

   typia.assert<Weeks>(x, { omit: ['__brand']}); // Check x is a Weeks, ignoring the brand field at runtime

This may be a less than optimal syntax, but the implementation would be to simply not generate type checks for the specified fields, ignoring them all together since they are ONLY referenced at compile time and never exist at runtime.

@samchon
Copy link
Owner

samchon commented Aug 7, 2024

How long do we have to repeat the same story?

This is runtime validator libary, so that no way to distinguish the brand type. If string & object type comes, how to detemine which one is valid and the other is not? Also, considering the object & object brand type case, can you make an algorithm which one to keep?

If you still want the brand type, consider to use custom tag.

Otherwise not, don't try to compel typia to adapt the nonsensible case. You're even forcibly casting by as statement, isn't it? Such anti-pattern like hard-casting is not suitable for validator feature.

@matAtWork
Copy link

I understand your story. However, you've not answered the point:

  • Typescript provides a common way to protect against dev/compile time errors using branding. It is INTENDED to generate an error if misused.
  • There is no run-time check that is possible to replicate this behaviour
  • Solution: allow the run-time checker (typia in this case) to ignore the brand.

Your current argument is "it's not possible as it's not compilable typescript", which ignores the fact it's both common and useful.

The link I provided is the canonical case - the type is a number, but has a member that never exists at runtime, however it is asserted at compile time to prevent assignments like "aWeek = aDay" - both variables are numbers so this is syntactically possible, but we choose to highlight this from the type systems as an error.

Again, the solution is to use some marker that the brand field should be checked at runtime. The use case is similar to the typia.tags.Type<"uint32"> intersection. In this case a typia.tags.NoRuntimeCheck would be ideal, as in:

export type Brand<K, T> = K & { __brand: T & typia.tags.NoRuntimeCheck  };

In this way, we're permitting the TS language server to enforce the type correctness, but asserting that the __brand field should not be checked at runtime.

Although I understand very well your irritation (I've worked on a few open source projects!), the use case is clear, and I've suggested some approaches to how it could work.

Since you clearly don't have the time to look into this yourself, perhaps you could point me at the correct place in the repo where I might implement typia.tags.NoRuntimeCheck and accept a PR?

@matAtWork
Copy link

Another way of looking at it is the typia.tags.NoRuntimeCheck is like the optional type marker you suggested, but ONLY at runtime, not dev/compile time

@samchon
Copy link
Owner

samchon commented Aug 7, 2024

image image

No matter how much you claim that "this is reasonable", in reality it is not.

The Brand type is contradictory and is an area that validators cannot support. When considering type spec, you have to consider the aspects of the TypeScript, "Checks the type as a set-inclusion relationship". You're not considering the set-inclusion relationship, and just saying "this is possible" based on your intuition.

Also, I don't have plan to adapt anti-pattern specification like Brand & tags.NoRuntimeCheck type.

@matAtWork
Copy link

matAtWork commented Aug 8, 2024

Dear Jeongho Nam, I have great respect for your technical capabilities. This is your project, and you should do as you see fit.

But your comment No matter how much you claim that "this is reasonable", in reality it is not. suggests you haven't considered many common use cases, or the purpose of TypeScript: to allow a wide range of typical errors to be determined at dev/compile time, instead of runtime.

Many typed languages support the concept of subclassed primitives. In Typescript, template types, keyof index types and types such as 'week' are all subclasses of string. In B (the precursor to C), pointers and integers were originally unified machine words, but became specialised in C (tho retaining some arithmetic operators). The syntax developed to help programmers avoid errors, but the implementation remains to this day as a "primtive" machine word.

Do you have an alternative proposal to allow subclassing of primitives? How would I create a type in TS that has all the properties of a number but can only be assigned/passed to a compatible type?

In your example

const error: BrandedNumber  = 3;

...you have misunderstood the use case. The correct usage would be:

const error = 3 as BrandedNumber; // or <BrandedBumber>3

... the purpose is to FORCE the developer to specify what type of number we are considering, in the same way that many declarations require a type in TypeScript, notably if they can't be inferred.

The mechanism for this is definitely imperfect (since TS does not have specific syntax for this), but not only does it work, it is used widely demonstrating it's utility.

And it is valid TypeScript: https://typia.io/playground/?script=JYWwDg9gTgLgBAIhgTzMAhgg3AKBysAUzgCE4BeOAOwFcQAjQqOAMjgG84B9eqdKgCYAuOAHJ6ouAF9cBYgGEK1Oo2ZtOPPoJGiAxpJl4AZjSq6YwCFTgAPABToRtBkwCUHKThNmLVuMgcREnd2T29zS2sAL0C4eRDPHF0rAGd4eiUARgAmAGY4dBTSXBx7elcsOAB6KrjoKEJzEQzgIvRlFygcAPLKmrqoBqa4FrbSHBje6tqAUUHoODt5esaYAEJXZrhW6gh4dsUcIA

Screenshot 2024-08-08 at 10 45 49

In JS, there are also real examples such as number & AsyncIterator<number> that are perfectly sensible types, since they actually used boxed types (as in let x = Object.assign(123,{ [Symbol.asyncIterator]() { ... } }). These should, of course, work as runtime as the fields actually exist, however it also fails in Typia.

This is demonstrated here: https://typia.io/playground/?script=JYWwDg9gTgLgBAIhgTzMAhgg3AKBysAUzgCE4BeOAOwFcQAjQqOAMjgG84B9eqdKgCYAuOAHJ6ouAF9cBYgGEK1Oo2ZtOPPoJGiAxpJl4AZjSq6YwCFTgAPABToRtBkwCUHKThNmLVuMgcREnd2T29zS2sAL0C4eRDPHF0rAGd4eiUARgAmAGY4dBTSXBx7elcsOAB6KrjoKEJzEQzgIvRlFygcAPLKmrqoBqa4FrbSHBje6tqAUUHoODt5esaYAEJXZrhW6gh4dsU8ZKoUiAAbQgA6M4gAczsMgCo4TIAGCumBoZgRHNzXoA

Screenshot 2024-08-08 at 10 31 14 (edited: wrong screenshot)

The assignment const x = Object.assign(123,{ u: 'u'}); is both valid Javascript and TypeScript, generating the correct type:

const x: 123 & {
    u: string;
}

...indicating that TS can indeed model such types, but Typia cannot generate a validator.

Rather this dismiss this class of problems - types which are correctly modelled in TypeScript but for which Typia cannot currently validate - could you explain your position that the type number & {...} is "nonsensical" when it is both executable JS and correctly modelled TS?

I ask this out of respect - I'm not trying to troll you! The second case above has nothing to do with branding, but is simply a TS type that can't be validated currently.

@matAtWork
Copy link

Looking at the function iterate_metadata_intersection, validating const x = Object.assign(123 as number /* not const */,{ u: 'u'}); above as:

console.log(typia.validate<Number & { u: string }>(x))

...works as expected (in a general sense - I've not confirmed the constant case).

Perhaps the solution to validating this type of intersection is to validate: primitive & object as Primitive & object?

@samchon
Copy link
Owner

samchon commented Aug 8, 2024

If you still want the brand type, how about determining a policy for the priorities between each element that composing the intersection type, especially about the nonsensible case.

Note that, the rule must not be ended by showing just simple intersection type like number & BrandType case and just saying "It is possible, it is simple". The policy must be possible to cover extreme situtation like combination of complicate intersection and union type case.

  • (A | B | C) & (D | E) & (F | G)
  • (((A | B | C) & (D | E) & (F | G)) & X) | ((a | b) & (c & d) & x)

@matAtWork
Copy link

I appreciate the extra insight. Perhaps we should ignore the "brand" case for now, and consider the Object.assign(num,{ ...}) case, since that generates the correct type within TypeScript. This does seem to be specifically about intersections of primitives with objects, in that objects can (of course) be intersected with themselves.

The difficulty comes from the fact, I guess, that the JS Object.assign(123,null) actually boxes the 123, as if you had typed new Number(123), but TypeScript claims it is number.

@matAtWork
Copy link

Given that boxed objects only have a single prototype chain, they can't be more than one primitive type. The intersection number & string is indeed nonsensible as you can't have a single identfier that has two different primitive prototypes

@matAtWork
Copy link

This would reduce the available cases to primitive & object, not primitiveA & primitiveB & object

@matAtWork
Copy link

In summary:

import typia from "typia";

let n = 123;
const x = Object.assign(n,{ u: 'u' }); // TS type: `const x: number & { u: string; }`
console.log(typia.validate<Number & { u: string }>(x)); // Works
// console.log(typia.validate<typeof x>(x)); - nonsensible

@matAtWork
Copy link

To fix the above example (not "branding", but auto-boxing), the issue can be resolved by correctly (or rather, more strictly) implementing the ObjectConstructor. The following code runs without error:

import typia from "typia";

type Boxed<T> =
    T extends number ? Number :
    T extends string ? String :
    T extends boolean ? Boolean :
    T extends bigint ? BigInt :
    T;

declare global {
    interface ObjectConstructor {
        assign<T extends {}, U>(target: T, source: U): Boxed<T> & U;
        new <T>(value?: T): Boxed<T>;
        <T>(value: T): Boxed<T>;
        }
}

let n = 123;
const x = Object.assign(n,{ u: 'u' });
console.log(typia.validate<typeof x>(x))

const nn = Object(456);
console.log(typia.validate<typeof nn>(nn));

https://typia.io/playground/?script=JYWwDg9gTgLgBDAnmYBDOAzKERwERIqp4DcAUGYQKZwBCEAHlQCYA8AKgHxwC8ZcAuOzhUGMKgDtmAZzgSAriABGVKHAD8cAHKKVagFz9Bw0eKmzpMKMAkBzDXADKVm-cOChIsZJlwlECAAbKlQJB3ogkLD3Yy8zXyVgWxt4TVokgEkJeBiBdnIyZioAY0DUKBpbQIglVEC4AG8jARTVDFRimgB5JQArEpgAYQgJSyh5YphoRuaPOFRpaSSJDjifWQaAXwAaOABVTgAKGHLbKhh9IV3pCHkoTsu9gEpL+iY2LjgAMn3yOcEJFQAO5wDhHABudXkVHUl3YLzojBYYL+-1BXEOkMC0LhCLeyK4qP+mzIJLIwXgYR4cAAjAAmADM5GKI0scAYvDgPX6kwAdAslrYJIcJNsGnB5JcAOTyKVwTZPZmsyK86q2Y7INC8rHAZiocSsagQDDso4MJ5PCgs0aUqlcvoDQ4AFgArAA2RVka03YKqiDqwhanV6g1Gk0SCRHCMWkhAA

@AlexRMU
Copy link
Contributor

AlexRMU commented Aug 15, 2024

And what is the problem with making the brand field optional? Everything will work exactly the same way.

@AlexRMU
Copy link
Contributor

AlexRMU commented Aug 15, 2024

I suggest making a special tag for this.

@matAtWork
Copy link

@AlexRMU - how do you mean "optional"?

type Weeks = number & { _brand: 'Week' }; // Fails in typia as "nonsensible"
type Weeks = number & { _brand?: 'Week' }; // Works in typia, but does not fail type-checking during assignment without a type assertion, which is the whole point of the technique

This is only partially true. See https://typia.io/playground/?script=JYWwDg9gTgLgBDAnmYBDOAzKERwERIqp4DcAUGYQKZwBCcAvHAHYCuIARlVHAGRwBvOAH0OUVMwAmAfgBccAOQcFcAL7lqcAMKMW7Lj35DR4qXMUBjFeooZWzCzGARmcAB4AKVPLaduASkFVMjsHJxc4RC95WkCBYNDHZ1cAL2jtOOCyATI4OAsXAGd4Dl0ARgAmAGZyXPcPDn8SODqoxua6tPa4AHoeuAB5CDBCgEI4ADkIOAB3VChmYGYAczIsnLyC5mK4Dll6JkqaijzPdtaGps7L5r64AFEobB4PLWgoKkdR-3lS4EKWBB4OgdGsKBt8kUSuVqnBUADaLVTjdev03k9PjBfnB-nC9H4oBdund0R9HNjcehaNdif1Hs84K93pjvhSAcwgXjQVkgA

...the only "failure" case is const b = 123; - this can now be passed to anything that expects a branded number irrespective of the brand, because the "brand" is optional.

My personal view is a typia.tag.NoRuntimeCheck would be ideal as it specifies that a member could be required at compile time, but absent at run-time. This is a rare edge case, as far as I know only useful to this use-case, but it logically solves the problem in a very easy to reason about way. @samchon doesn't like a run time validator that can optionally not validate, which I completely get, but it doesn't help solve this problem, which is used in codebases I'm responsible for extensively.

@MatAtBread
Copy link

For anyone interested, I implemented typia.tag.NoRuntimeCheck<T> to make by brands work in my fork at MatAtBread#1

See the notes on installation and the example:

import typia from "typia";

type B = number & { _brand: typia.tags.NoRuntimeCheck<'b'> };
type C = number & { _brand: typia.tags.NoRuntimeCheck<'c'> };

function x(a: number) {}
function y(a: B) {}
function z(a: C) {}

const b = 123 as B;

x(b); // Correct: b is a number
y(b); // Correct: b is a B
// @ts-expect-error
z(b); // Error (Correct!): b is not a C

console.log("Branding 1:",b * 10, typia.validate<B>(b)); // Correct: 1230

This works because an intersection between a single primitive and an "empty" object isn't 'nonsensible' in the current typia implementation. This tag marks the field for exclusion in the same test.

I've not submitted a PR to @samchon, as he's previously indicated he doesn't think "branding" is a reasonable TS use-case.

@samchon
Copy link
Owner

samchon commented Aug 19, 2024

@MatAtBread Yes, I still think that the reinterpret casting by as statement seems not valid.

If I needed the discrimination, I just utilzed actual brand type, and it is much type safer.

// UNSAFE
type Cat = string & { tag: "cat" };
type Dog = string & { tag: "dog" };
const cat: Cat = "something" as Cat;

// TYPE SAFE
type Cat = { name: string; kind: "cat" };
type Dog = { name: string; kind: "dog" };
const cat: Cat = { name: "kitty", kind: "cat" };

Whether I like the brand type or not, you are going to support brand type, you need to find a better solution. Looking at your code MatAtBread#1, NoRuntimeCheck is not a fundamental solution to the brand type what you want, but just avoiding the confliction with current typia system with hard coding.

To resolve this problem clearly, you have to design the priority rule. For example, when number & object (brand) type comes, you can make a rule that number takes precedence over object. Or you can design another rule that left side first and right side later. Such rule must be valid even when complicate intersection and union type comes, and there must not be any nonsensible case.

I repeat the previous example pseudo code again:

  • (A | B | C) & (D | E) & (F | G)
  • (((A | B | C) & (D | E) & (F | G)) & X) | ((a | b) & (c & d) & x)

@samchon samchon reopened this Aug 19, 2024
@MatAtBread
Copy link

MatAtBread commented Aug 19, 2024

The reason for the "branding", and not the type-safe solution you propose (which I accept is better), is because much of TS is about modelling what JS already does, not re-writing someone else's code. If a JS function in some 3rd party library takes a number as (for example) the number of Milliseconds, and another returns an RGB pixel as a number, saying:

timeSinceLastPaint(getPixel(0,0));

...looks typesafe, as getPixel(...) returns a number and timeSinceLastPaint expects number milliseconds, but it's clearly nonsense.

The preferred way to avoid this in TS is to "extend" number to be more specific, and currently the best way to do that is via the "brand" trick:

function timeSinceLastMillis(t: number & { _brand: 'milliseconds'}): void {}
function getPixel(x: number, y: number): number & { _brand: 'RGB565' } {}

When the code is in a module, or otherwise not part of our codebase, the "branding" pattern is a very useful way to say to the developer: "if you really want to do this, force the type". Without it, all numbers are just "numbers", which is actually rarely the case.

I completely accept that mine is the "trivial" solution, and I tried to come up with an implementation that was as minimal as possible so as not to have to understand all the clever stuff! I got quite into the typia codebase, but it's very clever ☺! I'm sure you could implement it in a better way if you had the inclination.

My solution rests on the fact that you do already permit the intersection of a primitive and an empty object. The tweak was to extend the definition of optional in this case only to fields tagged with NoRuntimeCheck. Otherwise, the logic is unchanged, so your pseudo code examples should work as before, as the test for a object type with no properties is only thing to have changed, not the walk down the type hierarchy.

@AlexRMU
Copy link
Contributor

AlexRMU commented Aug 19, 2024

What do you think about this?
It's not a complete solution, but it works right now.

@jfrconley
Copy link

In our project we used branded nominal types to assert format requirements

Something like:

// 0..255 regex is [0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]
// 0..31 regex is [0-9]|[12][0-9]|3[012]
// CIDR v4 is [0..255].[0..255].[0..255].[0..255]/[0..32]
/**
 * @pattern ^([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3}/([0-9]|[12][0-9]|3[012])$
 * @minLength 12
 * @maxLength 21
 * @example "86.255.0.199/24"
 */
export type CidrV4 = Nominal<string, "CidrV4">;

Nominal looks like this:

export declare class Tagged<N extends string> {
  protected _nominal_: N;
}

// The extra parameter "E" is for creating basic inheritance 
export type Nominal<T, N extends string, E extends T & Tagged<string> = T & Tagged<N>> = (T & Tagged<N>) | E;

We use this with our current runtime validator to generate checks such that both the requested type and all patterns match.
This means that the guarantees provided by runtime checking can be communicated to the type system.

For example:

function checkIpInCidrBlock(ip: IPv4, cidr: CidrV4): boolean {
  ...
}

const testCidr = "192.168.1.0/24"

// This will fail since the string type is too broad
checkIpInCidrBlock(testCidr)

// This narrows the string type to the nominal one
assertValid<CidrV4>(testCidr)

checkIpInCidrBlock(testCidr);

Missing support for this was one of our sticking points when evaluating typia, as this is a pattern we use a lot.
Nominal types really shine when you have runtime checks to back them up.

@eezing
Copy link

eezing commented Nov 1, 2024

Branding is valuable and it exists in other libraries (e.g. zod). At first glance, I instinctively assumed Typia was branding primitives with the tags types. Probably an incorrect assumption on my part.

This doesn't contribute to the solution, but I just wanted to highlight another popular method of branding.

Branding with Symbols example:

// brand.ts

// We don't export the Symbol to:
// 1. Prevent other code from forging branded types by manually adding the brand property
// 2. Prevent runtime attempts to access the non-existent branded property

const brand = Symbol('brand');

export type Brand<T, B> = T & { [brand]: B };
// volume.ts
import { Brand } from './brand';

export type Volume = Brand<number, 'Volume'>;

export function isVolume(value: unknown): value is Volume {
    return typeof value === 'number' && value >= 0 && value <= 11;
}
// usage.ts
import { Volume, isVolume } from './volume';

let myVolume: Volume | null = null;
const next = 10;

myVolume = next; // ❌ Error: Type 'number' is not assignable to type 'Volume'

if (isVolume(next)) {
    myVolume = next; // ✅ OK
}

@samchon

This comment was marked as outdated.

@sergio-milu
Copy link
Author

Wait for the next v7 update, then everything would be fixed.

seems like v7 has been released 🎉 , can you point us the to the change related with this issue? thanks :)

@samchon
Copy link
Owner

samchon commented Dec 2, 2024

import typia, { tags } from "typia";
 
typia.protobuf.message<IMember>();
 
interface IMember {
  id:
    | (string & tags.Sequence<11>)
    | (number & tags.Type<"uint64"> & tags.Sequence<12>)
    | (Uint8Array & tags.Sequence<13>);
  name: (string & tags.Sequence<20>) | null;
  children: Array<IMember> & tags.Sequence<30>;
  keywords: Map<string, string> & tags.Sequence<40>;
  thumbnail:
    | (string & tags.Format<"uri"> & tags.ContentMediaType<"image/*">)
    | Uint8Array;
  email: string & tags.Format<"email">;
  hobbies: Array<IHobby>;
}
interface IHobby {
  id: string & tags.Format<"uuid">;
  name: string;
  valid: boolean;
}

Even though v7 update has succeeded to extending the tagged intersection type to array or object types, still not easy to support the nonsensible type case in current tsc type system. No way to establish the validation logic at right now, and needs much more investigations, or give up the branded type.

Rather than supporting a very small number of use cases that are not used often and face all kinds of logical contradictions, I choose to delay for a long time. I would like to give up on implementing this feature for at least 6 months, as support for brand types interferes with typia's inherent correctness and JSON (or LLM function calling) schema construction. I also do open source development in my free time, and I don't have time to waste any more time on low important things.

If anyone wants to contribute, change the logic in the file below and try to pass the test program.

https://github.com/samchon/typia/blob/master/src/factories/internal/metadata/iterate_metadata_intersection.ts

@samchon samchon closed this as not planned Won't fix, can't repro, duplicate, stale Dec 2, 2024
@MatAtBread
Copy link

For those of us who's projects aren't "low important things", or "have time to waste", I have reapplied my patch to 7.0.1

Drop in install:

npm i typia@npm:@matatbread/[email protected]

Usage:

import typia from 'typia';

type X = number & { brand: typia.tags.NoRuntimeCheck<'X'> };
type Y = number & { brand: typia.tags.NoRuntimeCheck<'Y'> };

var x = 123 as X;
var y = 456 as Y;
x = y; // TS error at dev time:  Type '"Y"' is not assignable to type '"X"'.
y = x; // TS error at dev time:  Type '"X"' is not assignable to type '"Y"'.

const f = typia.assert<X>(x);  // Works: x is a number

console.log(f);

The typia.tags.NoRuntimeCheck simply suppresses the field check for (in this case) brand. It works because it's always been possible since type v6 to check primitive & {} (an empty object). This typia.tag simply makes the object look empty.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers invalid This doesn't seem right question Further information is requested wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests

8 participants