Typescript: Proposal: new "invalid" type to indicate custom invalid states

Created on 25 Apr 2018  路  24Comments  路  Source: microsoft/TypeScript

Proposal

A new invalid type that is not assignable to or from any other types. This includes not being assignable to or from any or never. It probably shouldn't even be assignable to invalid itself if that is possible, although I doubt that one really matters. I'd additionally suggest that, unlike other types, invalid | any is not reduced to any and invalid & never is not reduced to never.

The idea is to make sure that there is a compile error any time an invalid type is inferred or otherwise pops up in a users code.

invalid types would come from conditional types to represent cases where the conditional type author either expects the case to never happen, or expects that it might happen but intentionally wants that case to cause a compile error indicating to the user that something is invalid with the code they wrote.

The invalid type should also allow optionally passing an error message that would be displayed to the user when they encounter a compile error caused by the type that could give them a better idea of exactly what the problem is and how to fix it.

Motivating Examples

Allowing either true or false but not boolean - https://github.com/Microsoft/TypeScript/issues/23493#issuecomment-384369226

type XorBoolean<B extends boolean> = boolean extends B ? invalid<'only literal true or false allowed'> : boolean

declare function acceptsXorBoolean<B extends boolean & XorBoolean<B>>(arg: B): void

acceptsXorBoolean(true) // allowed
acceptsXorBoolean(false) // allowed

declare const unknownBoolean: boolean
acceptsXorBoolean(unknownBoolean)
// would have error message: 
// Argument of type 'boolean' is not assignable to parameter of type invalid<'only literal true or false allowed'>

It's possible to write the above example today(playground link) using never instead of invalid, but it generates an error message saying: Argument of type 'boolean' is not assignable to parameter of type 'never'. which is very likely to be confusing to a user who encounters it.

Preventing duplicate keys - https://github.com/Microsoft/TypeScript/issues/23413#issuecomment-381369843

type ArrayKeys = keyof any[]
type Indices<T> = Exclude<keyof T, ArrayKeys>
type GetUnionKeys<U> = U extends Record<infer K, any> ? K : never
type CombineUnion<U> = { [K in GetUnionKeys<U>]: U extends Record<K, infer T> ? T : never }
type Combine<T> = CombineUnion<T[Indices<T>]>

declare function combine<
  T extends object[] &
    {
      [K in Indices<T>]: {
        [K2 in keyof T[K]]: K2 extends GetUnionKeys<T[Exclude<Indices<T>, K>]> ? invalid<"Duplicated key"> : any
      }
    } & { "0": any }
    >(objectsToCombine: T): Combine<T>


const result1 = combine([{ foo: 534 }, { bar: "test" }]) // allowed

const error1 = combine([{ foo: 534, dupKey: "dup1" }, { bar: "test", dupKey: "dup2" }]) // error

Today(playground link) using never instead of invalid the error message for error1 is:

Argument of type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to parameter of type 'object[] & { "0": { foo: any; dupKey: never; }; "1": { bar: any; dupKey: never; }; } & { "0": any...'.
  Type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to type '{ "0": { foo: any; dupKey: never; }; "1": { bar: any; dupKey: never; }; }'.
    Types of property '"0"' are incompatible.
      Type '{ foo: number; dupKey: string; }' is not assignable to type '{ foo: any; dupKey: never; }'.
        Types of property 'dupKey' are incompatible.
          Type 'string' is not assignable to type 'never'

which would be basically impossible to understand if you didn't expect the function would reject duplicated keys. Using invalid<"Duplicated key"> however the error message could read:

Argument of type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to parameter of type 'object[] & { "0": { foo: any; dupKey: invalid<"Duplicated key">; }; "1": { bar: any; dupKey: invalid<"Duplicated key">; }; } & { "0": any...'.
  Type '[{ foo: number; dupKey: string; }, { bar: string; dupKey: string; }]' is not assignable to type '{ "0": { foo: any; dupKey: invalid<"Duplicated key">; }; "1": { bar: any; dupKey: invalid<"Duplicated key">; }; }'.
    Types of property '"0"' are incompatible.
      Type '{ foo: number; dupKey: string; }' is not assignable to type '{ foo: any; dupKey: invalid<"Duplicated key">; }'.
        Types of property 'dupKey' are incompatible.
          Type 'string' is not assignable to type 'invalid<"Duplicated key">'

Which gives a very clear hint that the problem is that dupKey is duplicated.

Conditional cases which should never happen

I could also see invalid potentially being used for some conditional types where there is a branch that presumably never gets taken because you are just using the conditional type for the infer capability. For example at the end of #21496 there is a type:

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R ? R : never;

Maybe invalid<"should never happen"> is used instead of never for the false branch so it's easier to track down the problem if it ever turns out the assumption that the branch will never be taken is wrong. (Of course if T is any, both the true and false branches are always taken so you might not want to change it away from never, but at least there'd be the option)

Related Issues

20235 - _Generics: Cannot limit template param to specific types_ - Could benefit from an approach like XorBoolean above.

22375 - _how do i prevent non-nullable types in arguments_ - Solution here is basically the same idea as XorBoolean. The error message for this specific issue is already understandable but it shows there is more interest in the pattern.

13713 - _[feature request] Custom type-error messages_ - Similar sounding idea, but it seems to be focused on changing the wording of existing error messages.

Search Terms

invalid type, custom error message, generic constraint, conditional types

In Discussion Suggestion

Most helpful comment

1000x yes to this.

As a library author it's tempting to go wild with the expressivity of TS and end up producing some lovely useful safe abstractions... which give awful unhelpful type error messages when users make mistakes. So I will often trade off nice abstractions in favour of nice error messages.

This feature would mean I could avoid making those tradeoffs. I could give users wild, beautiful, feels-good-man type abstractions at the same time as giving users clear, domain-specific error messages.

All 24 comments

invalid could also be a solution to the problem of the name global from #18433. Currently it is typed as never and is suggested to be changed to void, but neither of them is a complete solution that protects against accidental usage in every use case. invalid seems like it would prevent accidental usage in all cases though since it is defined to not be assignable to or from anything.

I've been playing around with conditional types a lot recently and what I currently do is,

("Some error message"|void|never) for "invalid" types.

Sometimes, I'll add other types to it,

("Unexpected inferred type"|InferredType|void|never)

Obviously, this is not ideal.

For one, it is not always possible to make complicated types work with the above workaround. Sometimes, you simply just have to use never.


I'd like to add an additional suggestion where it would be nice to be able to add information aside from a string error message.

Maybe have the Invalid<> type be a variadic generic,

class Class<A> {};

type Something<ClassT extends Class<any>> = (
    ClassT extends Class<infer A> ?
        (
            A extends number ?
                (9001) :
                (Invalid<"Expected inferred A, ", A, ", of , ClassT, " to extend ", number>)
        ) :
        (Invalid<"ClassT does not extend Class<> or could not infer A", ClassT>)
);

Implementation of this proposal would be a great help - combining it with conditional types and generic parameter defaults would allow for pretty precise generic constraints. It might also be worth to introduce a generic discard (_) to make it clear that given generic parameter is only a constraint (and possibly make it impossible to override it):

interface AcceptRequiredValue<
    T,
    _ = undefined extends T ? invalid<'undefined is not allowed'> : never,
    _ = null extends T ? invalid<'null is not allowed'> : never
> {
    value: T;
}

I'm on my phone right now but I have some types I use as error states at the moment,

//Just using `Error` as an invalid type marker but it's just an ugly hack
type Invalid1<T0> = [T0]|void|Error;
type Invalid2<T0, T1> = [T0, T1]|void|Error;
type Invalid3<T0, T1, T2> = [T0, T1, T2]|void|Error;
/*Snip*/

It works well enough for now but is unwieldy with more complicated types because it doesn't behave like never

I just have to say that almost every time I use a conditional type I wish this proposal or something like it were implemented. If I could give this issue more than one 馃憤 I would.

Another use case (also, proposing a type-level throw instead of introducing a new type, to indicate that we're now in a bad state and should break out of normal typechecking and mark downstream values as never):

function head<
  T extends any[],
  R = T[0] extends never
    ? throw 'Cant read head of empty array'
    : T[0]
>(array: T): R {
  return array[0]
}

let a = head([1,2,3]) // number
let b = head([]) // Error: Cant read head of empty array
let c = b + 5 // never

https://github.com/Microsoft/TypeScript/issues/26400

some more duplicates:

Would it be possible to view invalid as having a different 'kind' to ordinary TypeScript types? (Say Err rather than * as per Haskell). No run-time value has a type of kind Err, so anytime it pops up in something like an argument position we'll get a type error.

It probably shouldn't even be assignable to invalid itself if that is possible, although I doubt that one really matters.

I think it does matter, otherwise a conditional type with invalid could never be assignable to itself, or conditional types that are the same:

type Foo<T> = T extends 1 ? string : invalid<"some message">;
type Bar<T> = T extends 1 ? string : invalid<"some other message">;

function foo<T>(x: Foo<T>, y: Bar<T>) {
  x = y;  
  // ^ Error
  //    Type 'Bar<T>' is not assignable to type 'Foo<T>'.
  //    Type 'invalid<"some other message">' is not assignable to type 'invalid<"some message">'.

  x = x;
  // ^ Error
  //    Type 'Foo<T>' is not assignable to type 'Foo<T>'.
  //    Type 'invalid<"some message">' is not assignable to type 'invalid<"some message">'.
}

both assignments would be illegal if invalid was not assignable to itself because assignment for conditional types is done through congruence.

Perhaps just having a variable with a type that allows invalid should produce a type error?

In the example above I'd expect the signature function foo<T>(x: Foo<T>, y: Bar<T>) to be illegal because T does not extend 1.

Alternatively TypeScript could automatically constrain T so that no variable could possibly have a type that allows invalid.

This would really like to see this implemented, ideally as type-level throw. I use really thorough type-checking and the errors often look very confusing and unintuitive.

The way I handle invalid types in my code is something like this:

const StringExpected = {
  'TypeError: At least one of the arguments has to be string': Symbol()
};

function foo<T extends any[]>(
  ...args: UnionToIntersection<T[number]> extends string
           ? T
           : typeof StringExpected[]
) {}

foo(4, Symbol(), {}); // [ts] Argument of type '4' is not assignable to parameter of type '{ 'TypeError: At least one of the arguments has to be string': symbol; }'.
foo(4, Symbol(), ''); // OK

Finally, I'd just like to add some keywords, so that people are more likely to find the issue:

Keywords: _custom compile time error, custom early error, throw type, custom invalid type, throw in type declaration, conditional type error_

I figured I'd drop my current hack-y workaround for compile-time error messages over here.

It relies on how unknown behaves with type intersection, and conditional types.

The benefit of this workaround is that you do not pollute the return type unnecessarily.

The drawback is that calling functions that use this workaround (especially in a generic context) may be a little more tiresome.


type ErrorCheck<T extends number> = (
    Extract<42|69|1337, T> extends never ?
    unknown :
    ["You cannot use", T, "as an argument"]
);
declare function foo<T extends number>(
    n : T & ErrorCheck<T>
): string
/*
    Argument of type '42' is not assignable to parameter of type '42 & ["You cannot use", 42, "as an argument"]'.
    Type '42' is not assignable to type '["You cannot use", 42, "as an argument"]'.
*/
foo(42);

//OK
foo(32);

/*
    Argument of type 'number' is not assignable to parameter of type 'number & ["You cannot use", number, "as an argument"]'.
    Type 'number' is not assignable to type '["You cannot use", number, "as an argument"]'.
*/
declare const n: number;
foo(n);

declare const n2: 42 | 69 | 78;
//Long, ugly, error message
foo(n2);

///// Chaining calls/Generics

function bar<T extends number>(n: T) {
    //NOT OK; Long, ugly, error message
    return foo(n);
}

function baz<T extends number>(n: T & ErrorCheck<T>) {
    //Still NOT OK; Long, ugly, error message
    return foo(n);
}

function buzz<T extends number>(n: T & ErrorCheck<T>) {
    //OK!
    return foo<T>(n)
}

Playground

desperately need a special type (let's call it invalid) that would stop type checking anyfurther if gets deduced in a attempt to resolve a generic:

function f<A>(something: A): string {
    type A extends { text: string; } ? A : invalid; // might need to stop here if A turns out to be bad
    return something.text; // if we got here then A is { text: string; }
}

@aleksey-bykov Why don't you just do this?

function f<A extends { text: string; }>(something: A): string {
    return something.text;
}

well because my main use case is inferring types from generics via infer ..., and you cannot put constraints on a inferred type

+1

This would be great for creation of an XOR type, because the errors otherwise are unreadable unless you do the following.

type Without<T, U> = {
    [P in Exclude<keyof T, keyof U>]?: ["Property", P, "from type", T, "is mutually exclusive with the following properties in type", U, keyof U]
};

type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

interface Y { y: number };
interface Z { z: string };
var x: XOR<Y, Z> = {
    y: 2,
    z: ""
}

Playground

The following would be preferable. I'd just suggest sticking with interpolation syntax for consistency.

type Without<T, U> = {
    [P in Exclude<keyof T, keyof U>]?: throw `Property ${P} from type ${T} is mutually exclusive with the following properties in type ${U} : ${keyof U}`
};

If this syntax were accepted, a future addition could be a handle to the current instance of the object.

throw `Property ${P} from type ${T} is mutually exclusive with the following properties ${keyof Extract<U, typeof this>}`

This would restrict the error down to the properties actually used by the inline type.

1000x yes to this.

As a library author it's tempting to go wild with the expressivity of TS and end up producing some lovely useful safe abstractions... which give awful unhelpful type error messages when users make mistakes. So I will often trade off nice abstractions in favour of nice error messages.

This feature would mean I could avoid making those tradeoffs. I could give users wild, beautiful, feels-good-man type abstractions at the same time as giving users clear, domain-specific error messages.

I figured I'd drop my current hack-y workaround for compile-time error messages over here.

It relies on how unknown behaves with type intersection, and conditional types.

The benefit of this workaround is that you do not pollute the return type unnecessarily.

The drawback is that calling functions that use this workaround (especially in a generic context) may be a little more tiresome.

type ErrorCheck<T extends number> = (
    Extract<42|69|1337, T> extends never ?
    unknown :
    ["You cannot use", T, "as an argument"]
);
declare function foo<T extends number>(
    n : T & ErrorCheck<T>
): string
/*
    Argument of type '42' is not assignable to parameter of type '42 & ["You cannot use", 42, "as an argument"]'.
    Type '42' is not assignable to type '["You cannot use", 42, "as an argument"]'.
*/
foo(42);

//OK
foo(32);

/*
    Argument of type 'number' is not assignable to parameter of type 'number & ["You cannot use", number, "as an argument"]'.
    Type 'number' is not assignable to type '["You cannot use", number, "as an argument"]'.
*/
declare const n: number;
foo(n);

declare const n2: 42 | 69 | 78;
//Long, ugly, error message
foo(n2);

///// Chaining calls/Generics

function bar<T extends number>(n: T) {
    //NOT OK; Long, ugly, error message
    return foo(n);
}

function baz<T extends number>(n: T & ErrorCheck<T>) {
    //Still NOT OK; Long, ugly, error message
    return foo(n);
}

function buzz<T extends number>(n: T & ErrorCheck<T>) {
    //OK!
    return foo<T>(n)
}

Playground

Thanks a lot!

Just so you know, that parameter-error trick has limitations.

For example, type inference breaks for ReturnType<> if you use it inside the parameter list. I feel like it's a bug but gotta wait on the TS team, though.

https://github.com/microsoft/TypeScript/issues/29133#issuecomment-495752937

However, there is a workaround. It's more inconvenient but works with ReturnType<>+inference and makes the error message look cleaner. The drawback is the error span is not localized to the parameter anymore, and calling a function using the workaround is just... Ugly.

https://github.com/microsoft/TypeScript/issues/29133#issuecomment-495840886

@wongjiahau

So, I've been playing around with compile-time errors some more and I've had the following requirements,

  • Compile errors must be assignable to each other.

    CompileError<["errorA"]> must be assignable to CompileError<["errorB"]>

    This is important because it lets us change the error message between versions of our packages
    and not break backwards compatibility.

  • It should not be possible to "legitimately" create a value of type CompileError<>


With the above requirements, my hacks using tuples ["errorA"] or union types ["errorA"]|void don't work out.

  • ["errorA"] is not assignable to ["errorB"]

  • You can easily create a value of type ["errorA"]


I've revised my hack for compile-time errors but it still has the limitation that you should not use ReturnType<> inside of function parameter lists.

So, do not use function foo<F extends () => number> (f : F).
Instead, use function foo<N extends number> (f : () => N).


Below is a revised version of the hack and examples of how it works,

/**
 * We should never be able to create a value of this type legitimately.
 * 
 * `ErrorMessageT` is our error message
 */
interface CompileError<ErrorMessageT extends any[]> {
  /**
   * There should never be a value of this type
   */
  readonly __compileError : never;
}
type ErrorA = CompileError<["I am error A"]>;
type ErrorB = CompileError<["I am error B"]>;

declare const errorA : ErrorA;
/**
 * Different compile errors are assignable to each other.
 */
const errorB : ErrorB = errorA;

/**
 * Pretend this is `v1.0.0` of your library.
 */
declare function foo <N extends number> (
  /**
   * This is how we use `CompileError<>` to prevent `3` from being
   * a parameter
   */
  n : (
    Extract<3, N> extends never ?
    N :
    CompileError<[3, "is not allowed; received", N]>
  )
) : void;

/**
 * Argument of type '3' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", 3]>'.
 */
foo(3);
/**
 * OK!
 */
foo(5);
/**
 * Argument of type '3 | 5' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", 3 | 5]>'.
 */
foo(5 as 3|5);
/**
 * Argument of type 'number' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", number]>'.
 */
foo(5 as number);

///////////////////////////////////////////////////////////////////

/**
 * The same as `foo<>()` but with a different error message.
 * 
 * Pretend this is `v1.1.0` of your library.
 */
declare function bar <N extends number> (
  n : (
    Extract<3, N> extends never ?
    N :
    //Different error message
    CompileError<[3, "is not allowed; received", N, "please try again"]>
  )
) : void;

/**
 * Assignable to each other.
 * 
 * This means we can change the error message across different
 * package versions and it will not be considered a breaking change!
 * 
 * Users can pass types using `CompileError<>`
 * of `v1.0.0` of your library to `v1.1.0`
 * of your library without worrying!
 */
const fooIsAssignableToBar : typeof bar = foo;
const barIsAssignableToFoo : typeof foo = bar;

///////////////////////////////////////////////////////////////////

/**
 * If a different library defines their own `CompileError<>` type,
 * it's okay!
 * 
 * As long as it has the same "shape" as our own `CompileError<>` type.
 */
interface OtherCompileError<ErrorMessageT extends any[]> {
  /**
   * There should never be a value of this type
   */
  readonly __compileError : never;
}
type ErrorC = OtherCompileError<["I am error C"]>;
/**
 * Different compile errors are assignable to each other.
 */
const errorC : ErrorC = errorA;

///////////////////////////////////////////////////////////////////

declare function doNotReturn3 <N extends number> (
  /**
   * This is how we use `CompileError<>` to prevent `3` from being
   * a return value
   */
  n : (
    Extract<3, N> extends never ?
    () => N :
    CompileError<[3, "is not allowed; received", N]>
  )
) : void;


/**
 * Argument of type '3' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", 3]>'.
 */
doNotReturn3(() => 3);
/**
 * OK!
 */
doNotReturn3(() => 5);
/**
 * Argument of type '3 | 5' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", 3 | 5]>'.
 */
doNotReturn3(() => (5 as 3|5));
/**
 * Argument of type 'number' is not assignable to parameter of type
 * 'CompileError<[3, "is not allowed; received", number]>'.
 */
doNotReturn3(() => (5 as number));

///////////////////////////////////////////////////////////////////
//Below is a more complicated example of using `CompileError<>`

interface Column {
  name : string,
  dataType : string|number|boolean,
}

type ColumnNameExcludeIndex<ArrT extends Column[], IndexT extends keyof ArrT> = (
  {
    [k in Exclude<keyof ArrT, IndexT>] : (
      number extends k ?
      never :
      ArrT[k] extends Column ?
      ArrT[k]["name"] :
      never
    )
  }[Exclude<keyof ArrT, IndexT>]
);

//type columnNameTest = "a" | "c"
type columnNameTest = ColumnNameExcludeIndex<
  [
    { name : "a", dataType : string },
    { name : "b", dataType : string },
    { name : "c", dataType : string },
  ],
  "1"
>;

type DuplicateColumnNames<ArrT extends readonly Column[]> = (
  {
    [k in keyof ArrT] : (
      ArrT[k] extends Column ?
      (
        ArrT[k]["name"] extends ColumnNameExcludeIndex<ArrT, k> ?
        ArrT[k]["name"] :
        never
      ) :
      never
    )
  }[number]
);

//type duplicateColumnNamesTest = "a"
type duplicateColumnNamesTest = DuplicateColumnNames<[
  { name : "a", dataType : string },
  { name : "b", dataType : string },
  { name : "c", dataType : string },
  { name : "a", dataType : number },
]>;

type NonEmptyTuple<T> = (
  readonly T[] & { readonly "0" : T }
);

declare function selector<ColumnsT extends Column[]> (
  ...columns : ColumnsT
) : (
  {
    select : <ArrT extends (ColumnsT[number])[]> (
      callback : (
        DuplicateColumnNames<ArrT> extends never ?
        (columns : ColumnsT) => ArrT & NonEmptyTuple<Column> :
        CompileError<[
          "Duplicate column names not allowed in select",
          DuplicateColumnNames<ArrT>
        ]>
      )
    ) => ArrT
  }
)

const s = selector(
  { name : "a", dataType : "blah" } as const,
  { name : "b", dataType : "blah" } as const,
  { name : "c", dataType : "blah" } as const,
);
/*
  const arr : [
    {
        readonly name: "a";
        readonly dataType: "blah";
    },
    {
        readonly name: "b";
        readonly dataType: "blah";
    }
  ]
*/
const arr = s.select(columns => [
  columns[0],
  columns[1],
]);

/**
 * Argument of type
 * '(columns: [
 *    { readonly name: "a"; readonly dataType: "blah"; },
 *    { readonly name: "b"; readonly dataType: "blah"; },
 *    { readonly name: "c"; readonly dataType: "blah"; }
 * ]) => [
 *    { readonly name: "a"; readonly dataType: "blah"; },
 *    { ...; }
 * ]'
 * is not assignable to parameter of type
 * 'CompileError<["Duplicate column names not allowed in select", "a"]>'.
*/
s.select(columns => [
  columns[0],
  columns[0],
]);

Playground


The s.select() example is the most complex code snippet but it shows that we can have pretty complex compile-time checks if we are creative enough.


Also note that typeof foo and typeof bar are assignable to each other, even though they have different error messages. This is useful so we can change error messages between versions of packages and not break backwards compatibility.

A user may use both v1.0.0 and v1.1.0 of your library and pass types from v1.0.0 to v1.1.0 and vice versa. If we just use regular tuples, changing the error message means the error message types are no longer compatible.


Native support for this would be cool.

In particular, notice that the s.select() example has a crazzzyyyyy long error message!
What we are really interested in is the 'CompileError<["Duplicate column names not allowed in select", "a"]>' part.
However, we have to scroll pretty far down to see it.

When types start becoming thousands of lines long (it happens to me), this is a PITA


In my opinion, the CompileError<> type only really makes sense in the context of function parameter lists, at the moment.

If you could somehow make it so that a resolved return type of a generic function containing CompileError<> really causes a compile error, then it would make sense to have it there, too.

But such a thing would require native support for this type.

Otherwise, it would be easy for the following to happen,

declare function neverCompile<T> (t : T) : T extends any ? CompileError<["I will never compile"]> : never;
//Expected: Compile error
//Actual: Compiles fine
neverCompile("please explode");

If you're getting 'ErrorMessageT' is declared but its value is never read.ts(6133),
just change it to _ErrorMessageT

Another use-case for compile-time error types,

/**
    Gives you the string keys of the type.
*/
export type PathPartOf<T> = (
    T extends object ?
    Extract<keyof T, string> :
    never
);

/**
    Gives you the result of traversing `T` by the path part.
*/
export type Traverse<T, PathPartT extends PathPartOf<T>> = (
    T extends null ?
    null :
    T extends undefined ?
    undefined :
    T extends any ?
    (
        PathPartT extends keyof T ?
        T[PathPartT] :
        never
    ) :
    never
);

export type PopFront<TupleT extends any[]> = (
    ((...args : TupleT) => void) extends ((head : any, ...tail : infer TailT) => void) ?
    TailT :
    never
);


type OptionalChainingImpl<T, ArrT extends string[]> = (
    {
        0 : unknown,
        1 : T,
        2 : (
            ArrT[0] extends PathPartOf<T> ?
            (
                OptionalChainingImpl<
                    Traverse<T, ArrT[0]>,
                    PopFront<ArrT>
                >
            ) :
            unknown
        )
    }[
        number extends ArrT["length"] ?
        0 :
        ArrT["length"] extends 0 ?
        1 :
        2
    ]
);
type OptionalChaining<T, K extends keyof T, ArrT extends string[]> = (
    OptionalChainingImpl<T[K], ArrT>
);
type AssertValidPathImpl<T, ArrT extends string[]> = (
    {
        0 : ["cannot check if path is valid during compile-time"],
        1 : unknown,
        2 : (
            ArrT[0] extends PathPartOf<T> ?
            (
                AssertValidPathImpl<
                    Traverse<T, ArrT[0]>,
                    PopFront<ArrT>
                >
            ) :
            ["Invalid path part", ArrT[0], "Expected", PathPartOf<T>]
        )
    }[
        number extends ArrT["length"] ?
        0 :
        ArrT["length"] extends 0 ?
        1 :
        2
    ]
);
type AssertValidPath<T, K extends keyof T, ArrT extends string[]> = (
    AssertValidPathImpl<T[K], ArrT>
);
declare function optionalChaining<T, K extends keyof T, ArrT extends string[]> (
    t : T & AssertValidPath<T, K, ArrT>,
    k : K,
    ...arr : ArrT
) : (
    OptionalChaining<T, K, ArrT>
);

type ComplexObj = {
    a : {
        b : undefined|{
            c : null|{
                d : Date
            }
        }
    }
};
declare const obj : ComplexObj;

/*
{
    b: {
        c: {
            d: Date;
        } | null;
    } | undefined;
}
*/
const a = optionalChaining(obj, "a");

/*
{
    c: {
        d: Date;
    } | null;
} | undefined
*/
const a_b = optionalChaining(obj, "a", "b");

/*
{
    d: Date;
} | null | undefined
*/
const a_b_c = optionalChaining(obj, "a", "b", "c");

/*
Date | null | undefined
*/
const a_b_c_d = optionalChaining(obj, "a", "b", "c", "d");

/*
'["Invalid path part", string, "Expected", "c"]'
*/
const a_b_string = optionalChaining(obj, "a", "b", "" as string);


/*
'["Invalid path part", "xyz", "Expected", "c"]'
*/
const a_b_xyz = optionalChaining(obj, "a", "b", "xyz");

Playground

(See the last two examples)


[Edit]

I know, we already have optional chaining coming up.
But maybe the function can return the string keys joined with "." or something.
A type-safe way to build dot-separated object paths.


Also, I think I wrote the above example before coming up with the CompileError<> workaround.

You should substitute those string-tuple-as-error-message workarounds for CompileError<["some error", "message"]>

It's safer for the implementation that way

I'll be updating this comment over the period of a few hours with my thoughts on how my ideal implementation would behave.

I'll be using the CompileError<> syntax.


Use Case

The all-important question.

In the examples that follow, demonstrating intended behaviour, the examples will all be contrived.

Just so the usefulness of this feature isn't in doubt, you may refer to this link for a list of real and complex use cases.

https://github.com/AnyhowStep/tsql/search?l=TypeScript&p=2&q=CompileError

In a project I'm working on, the CompileError<> type is used to emulate a subset of features of the desired invalid type.

The library performs compile-time checks on SQL queries and ensures they are always syntactically valid and type-safe, when executed.


Intended Behavior (in my opinion),

Type Alias Behaviour

The CompileError<> type should result in an actual compile error when instantiated concretely.

In generic contexts, no compile error is given.

//CompileError type instantiated concretely
//leads to actual compile error
type E0 = CompileError<["description", 0]>;
//        ~~~~~~~~~~~~ Custom compile error encountered: ["description", 0]

//CompileError type in generic context
//does not lead to actual compile error
type E1<T> = CompileError<T>;

//CompileError type in generic context
//does not lead to actual compile error
type E2<T> = CompileError<"a">;


//CompileError type instantiated concretely
//leads to actual compile error
type E3 = E1<void>;
//        ~~ Custom compile error encountered: void

//CompileError type in generic context
//does not lead to actual compile error
type NoNumber<T> = Extract<T, number> extends never ?
    T :
    CompileError<["The following types are not allowed", Extract<T, number>]>;

//CompileError type instantiated concretely
//leads to actual compile error
type E4 = NoNumber<string|1|2n|(3&{x:4})>;
//        ~~~~~~~~ Custom compile error encountered: ["The following types are not allowed", 1|(3&{x:4})]

//Conditional type resolves to string|1337n
//So, no compile error
type NoError0 = NoNumber<string|1337n>;


Conditional Type Branch Behaviour

If the conditional type can be evaluated immediately (not deferred because of a generic type param),
then we ignore CompileError<> types in unevaluated branches.

//Resolves to `CompileError<"n">`
type E0 = number extends string ? "y" : CompileError<"n">;
//                                      ~~~~~~~~~~~~ Custom compile error encountered: "n"

//Resolves to `"n"`
//No compile error because true branch is not evaluated
type E1 = number extends string ? CompileError<"y"> : "n";

//Resolves to `"y"`
//No compile error because false branch is not evaluated
type E2 = "beep" extends string ? "y" : CompileError<"n">;

//Resolves to `CompileError<"y">`
type E3 = "beep" extends string ? CompileError<"y"> : "n";
//                                ~~~~~~~~~~~~ Custom compile error encountered: "y"

//Resolves to `CompileError<"y">|CompileError<"n">`
type E4 = any extends string ? CompileError<"y"> : CompileError<"n">;
//                             ~~~~~~~~~~~~ Custom compile error encountered: "y"
//                                                 ~~~~~~~~~~~~ Custom compile error encountered: "n"

If the conditional type is deferred, then we ignore CompileError<> on both branches.

function foo<T> (t : T) {
    //No compile error
    //Evaluation deferred
    type E0 = T extends string ? CompileError<"y"> : CompileError<"n">;

    //Resolves to CompileError<"n">
    type E1 = number extends string ? "y" : CompileError<"n">;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: "n"

    //Resolves to CompileError<T>
    type E2 = number extends string ? "y" : CompileError<T>;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: T

}

extends Behaviour

The types never and CompileError<> are considered sub types of each other, for the purpose of type checking.

The rationale is that the code should not compile successfully, let alone run, when a concrete custom compile error is encountered.

So, a value of type CompileError<> should essentially never exist.

//Gives two compile errors but resolves to "y"
type X0 = CompileError<"a"> extends CompileError<"b"> ? "y" : "n";
//        ~~~~~~~~~~~~ Custom compile error encountered: "a"
//                                  ~~~~~~~~~~~~ Custom compile error encountered: "b"


//Gives a compile error but resolves to "y"
type X1 = CompileError<"a"> extends never ? "y" : "n";
//        ~~~~~~~~~~~~ Custom compile error encountered: "a"

//Gives a compile error but resolves to "y"
type X2 = never extends CompileError<"b"> ? "y" : "n";
//                      ~~~~~~~~~~~~ Custom compile error encountered: "b"

function foo<T> (t : T) {
    //Gives two compile errors but resolves to "y"
    type X3 = CompileError<"a"> extends CompileError<"b"> ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: "a"
    //                                  ~~~~~~~~~~~~ Custom compile error encountered: "b"


    //Gives a compile error but resolves to "y"
    type X4 = CompileError<"a"> extends never ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: "a"

    //Gives a compile error but resolves to "y"
    type X5 = never extends CompileError<"b"> ? "y" : "n";
    //                      ~~~~~~~~~~~~ Custom compile error encountered: "b"

    //Gives two compile errors but resolves to "y"
    type X6 = CompileError<T> extends CompileError<"b"> ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: T
    //                                ~~~~~~~~~~~~ Custom compile error encountered: "b"

    //Gives a compile error but resolves to "y"
    type X7 = CompileError<T> extends never ? "y" : "n";
    //        ~~~~~~~~~~~~ Custom compile error encountered: T

    //Gives a compile error but resolves to "y"
    type X8 = never extends CompileError<T> ? "y" : "n";
    //                      ~~~~~~~~~~~~ Custom compile error encountered: T

    //Resolves to `CompileError<"nope">`
    type X9 = number extends string ? "y" : CompileError<"nope">;
    //                                      ~~~~~~~~~~~~ Custom compile error encountered: "nope"

    //Resolves to "y"
    //No compile error
    type X10 = number extends number ? "y" : CompileError<"nope">;

    //Deferred, no compile error
    type X11 = T extends any ? CompileError<"yeap"> : CompileError<"nope">;

    //Deferred, no compile error
    type X12 = T extends CompileError<T> ? CompileError<"yeap"> : CompileError<"nope">;
}

Type Parameter Behaviour

The CompileError<T> type may be used as a default type argument without triggering an actual compile error. This can be used to force users to specify an explicit type parameter.

I have seen this requested over Gitter many times. Google might also somewhat benefit from it, with their return-only generics.

https://github.com/microsoft/TypeScript/issues/33272

//No compile error when used as
//default type argument
type Explicit<T=CompileError<"Explicit type argument required">> = T;

declare const explicit0 : Explicit;
//                        ~~~~~~~~ Custom compile error encountered: "Explicit type argument required"

//No compile error
declare const explicit1 : Explicit<void>;

declare class MySet<T=CompileError<"Explicit type argument required">> () {
    add (t : T) : void;
    get () : T;
}
const mySetErr = new MySet();
//                   ~~~~~ Custom compile error encountered: "Explicit type argument required"

const mySetOk = new MySet<number>(); //OK!

Because CompileError<> is also a bottom type like never, we can have this,

//No compile error when used as
//default type argument
//`CompileError<>` is subtype of all types like `never`
type Explicit<T extends number=CompileError<"Explicit type argument required">> = T;

declare const explicit0 : Explicit;
//                        ~~~~~~~~ Custom compile error encountered: "Explicit type argument required"

declare const explicit1 : Explicit<void>;
//                                 ~~~~ `void` is not assignable to `number`

//No compile error
declare const explicit2 : Explicit<32>;

declare function foo<T extends number=CompileError<"Explicit type argument required">> () : ComplicatedType<T>;
const x = foo();
//        ~~~ Custom compile error encounted: "Explicit type argument required"

const y = foo<9001>(); //OK

Variable Behaviour

Different CompileError<> types are assignable to each other, since they all function as never,

declare let a : CompileError<"a">;
//              ~~~~~~~~~~~~ Custom compile error encountered: "a"
declare let b : CompileError<"b">;
//              ~~~~~~~~~~~~ Custom compile error encountered: "b"

a = b; //OK
b = a; //OK

declare let foo : <T>(t : T) => T extends number ? CompileError<"haha, no numbers plz"> : T;
declare let bar : <T>(t : T) => T extends number ? CompileError<"numbers not welcome!"> : T;
declare let baz : <T>(t : T) => T extends number ? never                                : T;

foo = bar; //OK
foo = baz; //OK
bar = foo; //OK
bar = baz; //OK
baz = foo; //OK
baz = bar; //OK

Property Behaviour

interface IFoo<T> {
    //No compile error
    prop : T extends number ? CompileError<[T, "not allowed"]> : T;
}

const x : IFoo<number> = { prop : 3 };
//        ~~~~ Custom compile error encountered at property 'prop': [number, "not allowed"]
//                                ~ `3` is not assignable to `CompileError<[number, "not allowed"]>`

Parameter Behaviour

Custom compile errors at function parameters can be emulated at the moment,
but are hacky and messy.

You can see the hack in action through this link,
https://github.com/AnyhowStep/tsql/search?l=TypeScript&p=2&q=CompileError

//CompileError type in generic context
//does not lead to actual compile error
type AssertNonNumber<T> = Extract<T, number> extends never ?
    T :
    CompileError<["The following types are not allowed", Extract<T, number>]>;

declare function foo<T>(t : T & AssertNonNumber<T>) : T;

foo("hi"); //OK

foo(54);
//  ~~ Custom compile error encountered: ["The following types are not allowed", 54]

//"hi"
const hi = foo("hi"); //OK

//54
const n = foo(54);
//            ~~ Custom compile error encountered: ["The following types are not allowed", 54]

function bar<T> (t : T) : T {
    return foo<T>(t);
    //         ~ `T` is not assignable to `T & AssertNonNumber<T>`
}

function baz<T> (t : T & AssertNonNumber<T>) : T {
    return foo<T>(t); //OK
}

function baz<T> (t : T & AssertNonNumber<T> & SomeOtherCheck<T>) : T {
    return foo<T>(t); //OK
}


Return Type Behaviour

At the moment, there is no workaround to enable real return type compile error behaviour.

declare foo<T> (t : T) : T extends number ? CompileError<["number", T, "not allowed"]> : T

foo("hi"); //OK

foo(54); //Custom compile error encountered: ["number", 54, "not allowed"]

//"hi"
const hi = foo("hi"); //OK

//CompileError<["number", 54, "not allowed"]>
const n = foo(54); //Custom compile error encountered: ["number", 54, "not allowed"]


Sample Complex Use Case

The following complex use case comes from a project I'm working on,

https://github.com/AnyhowStep/tsql/blob/e7721dbda5e99acf77a90eeb148e8ade3313fbd2/src/on-clause/util/predicate/assert-no-outer-query-used-ref.ts#L56-L72


In MySQL, the following query is invalid,

SELECT
    *
FROM
    myTable
WHERE
    (
        SELECT
            myTable2.myTable2Id
        FROM
            myTable2
        JOIN
            myTable3
        ON
            -- myTable.myTableId is an outer query column
            myTable3.myTable3Id = myTable.myTableId
        LIMIT
            1
    ) IS NOT NULL

A subquery cannot reference outer query columns in the ON clause in MySQL.

The above query will result in a compile-time error,

CompileError<[\"ON clause must not reference outer query tables\", \"myTable\"]>

I'm working on this with PR #40336, I think it will be super powerful.

PR at #40402

Was this page helpful?
0 / 5 - 0 ratings

Related issues

manekinekko picture manekinekko  路  3Comments

kyasbal-1994 picture kyasbal-1994  路  3Comments

remojansen picture remojansen  路  3Comments

uber5001 picture uber5001  路  3Comments

DanielRosenwasser picture DanielRosenwasser  路  3Comments