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

Support Const Type Constraint #41114

Closed
5 tasks done
woshiguabi opened this issue Oct 15, 2020 · 23 comments · Fixed by #51865
Closed
5 tasks done

Support Const Type Constraint #41114

woshiguabi opened this issue Oct 15, 2020 · 23 comments · Fixed by #51865
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@woshiguabi
Copy link

woshiguabi commented Oct 15, 2020

Search Terms

Const Type Constraint Literal

Suggestion

Add new syntax const T or a global generic type Constant<T = any> = T extends const T ? T : never;

type ConstString = const string is same as type ConstString = Constant<string>.

  • If T extends string | number | boolean | symbol | bigint | null | undefined, requires T to be a constant.
  • If T is object or Array, requires every member of T to be a constant.
  • If T is enum, nothing to do, because enum is same as const enum

The difference with union literal is that type 'foo' | 'bar' can only be 'foo' or 'bar', but type const string can be any constant string.

Use Cases

  • Sometimes we want to constrain a type or a parameter to be literal.
  • It can help the TS compiler to recognize constant types better

Examples

let foo = 'foo'; // string
const bar= 'bar'; // 'bar'

const notConstant: const string = foo; // error
const trulyConstant: const string = bar; // success

type Baz = { baz: string } ;
const baz1: const Baz = { baz: 'baz' }; // success
const baz2: Baz = { baz: 'baz' }; // not a constant
const baz3: Baz  = { baz: 'baz' } as const; // current available, same as baz1

type ConstArray<T> = const Array<T>;
// same as
type AnotherCosntArray<T> = Array<const T>;

function tuple<T extends ConstArray>(...args: T) {
  return args;
}
const t1 = tuple(foo); // error!
const t2 = tuple(bar); // success! typeof t2 should be ['bar']
const t3 = tuple('a', 1, true, ['1'], baz1) // ['a', 1, true, ['1'], { baz: 'baz' }];
const t4 = tuple(baz2) // error!

let variable: const string = 'variable'; // this is possible
variable = 'another variable'; // success
variable = document.title; // error

let reactiveObject: const { foo: string } = { foo: 'foo' }; // success;
reactiveObject.foo = 'bar' // success

In fact, const only affects the assignment behavior, and a const type is considered to be the same as a non const type when read it.
A const type doesn't mean immutable, just means every primitive value should be literal, so maybe should call it literal type.

Alternatives

  • Only add extends const syntax, and add global generic type Constant<T = any> = T extends const ? T : never;
  • Use finally or static or literal(not exist in current keywords) keyword instead of const
  • Use type Literal instead of type Constant.

Related issues

#30680 Suggestion: Const contexts for generic type inference

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@MartinJohns
Copy link
Contributor

Sometimes we want to constrain a type or a parameter to be literal.

Can you provide a real world example where this would be useful?

@woshiguabi
Copy link
Author

woshiguabi commented Oct 15, 2020

Sometimes we want to constrain a type or a parameter to be literal.

Can you provide a real world example where this would be useful?

function tuple<T extends Array<string | number>>(...args: T) {
  return args;
}
const t1 = tuple('a', 'b'); // ['a', 'b']
let bar = 'bar';
const t2 = tuple('foo', bar); // ['foo', string]

For intellisense, parameter must be literal.

@Constantiner
Copy link

Why not to use just:

function tuple<T extends Array<string | number>>(...args: T) {
  return args;
}
const t1 = tuple('a', 'b'); // ['a', 'b']
let bar = 'bar' as const;
const t2 = tuple('foo', bar); // ['foo', 'bar']

?

@woshiguabi
Copy link
Author

woshiguabi commented Oct 15, 2020

Why not to use just:

function tuple<T extends Array<string | number>>(...args: T) {
  return args;
}
const t1 = tuple('a', 'b'); // ['a', 'b']
let bar = 'bar' as const;
const t2 = tuple('foo', bar); // ['foo', 'bar']

?

tuple may come from package.
The main goal is to constrain function parameters must be literal.
Now we have no way to diff const type and variable type.

@MartinJohns
Copy link
Contributor

The main goal is to constrain function parameters must be literal.

But you didn't provide an example where this would be useful.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Oct 16, 2020
@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 22, 2020

This could be accomplished with the conditionally assignable types discussed in #40779.

type StrictSubtype<T> = match U extends T => T extends U ? false : true;
type ConstString = StrictSubtype<string>;

@harrysolovay
Copy link

The main use case––seems to be––to help library developers ensure that consumers are passing narrowed objects:

// BAD
acceptsNarrowed({
  a: "A",
  b: true,
  c: 3,
});

// GOOD
acceptsNarrowed({
  a: "A",
  b: true,
  c: 3,
} as const);

This feature would really shine! I've run into this problem many times. The following would be incredibly useful to me personally.

declare function acceptsNarrowed<O extends Constant<Record<PropertyKey, any>>>(o: O): void;
// or
declare function acceptsNarrowed<O extends const Record<PropertyKey, any>>(o: O): void;

@KilianKilmister
Copy link

The main goal is to constrain function parameters must be literal.

But you didn't provide an example where this would be useful.

I have to agree with @harrysolovay. I run into problems around this regularly, usually with some complex type-converting breaking down when it resieves non-literal string or number (especially when using the upcoming TempateString-literal type).

Also, i think this could solve many of the issues related to the notoriously strange symbol type. just to mention a few:

  • precise symbol being returned from a function
  • specifying a precise symbol in function parameters
  • dynamic object keying with symbols
  • symbol indexing

This would make it possible to give symbols to an API that can be referenced at a later point, both internally and by the API consumer.

// has the incredibly useless returntype of `{[x: string]: string }`
function someApi <T extends symbol>(someSymbol: T) {
  return {[someSymbol]: 'dynamic symbol key'}
}

// return type is `symbol`, making it impossible to reference it again in the typeSystem.
function GetDynamicallyUniqueSymbol () {
  const sym = Symbol('unique')
  return sym
}

@woshiguabi
Copy link
Author

After a few thinking, I think there are still some unresolved problems in the proposal.


Object and Array type probably shouldn't extends const to every member, because of prototype.

const foo = (arr: Constant<Array<string>>) => arr;

If object member extends const, Array.prototype.length will be const, so this function cannot be called.

Use this instead.

const bar = (arr: Array<Constant<string>>) => arr;

What about funciton type? Seems function type should not be affected by const.

We need more discussion.


This could be accomplished with the conditionally assignable types discussed in #40779.

type StrictSubtype<T> = match U extends T => T extends U ? false : true;
type ConstString = StrictSubtype<string>;

@tjjfvi That will be a greet solution! I think generic is much better than a new syntax.

@KilianKilmister
Copy link

@woshiguabi

That will be a greet solution! I think generic is much better than a new syntax.

I have to be nitpickey here, but this is also new syntax. I also don't think this would address the issue in this thread. They do have overlap tho.

const foo = (arr: Constant<Array<string>>) => arr;

If object member extends const, Array.prototype.length will be const, so this function cannot be called.

I don't see a reason why this couldn't be called. I would interpret that as: the function only takes arrays with a known length (so eg. no string[]), but the specific length doesn't matter.
But it's good that you're brining these thing up.

What about funciton type? Seems function type should not be affected by const.

I have now idea how const should behave on a function directly. Maybe make everything that can be a litteral require a literal? But i don't think that would be a good idea as people could do the same with the individual params/return types. I don't think it should be usable on a function type, but it should stay similarly limited like a as const assertion.

There has been a want for something like this (or like the other proposal) for quite some time. There was a suggestion about extending the unique type to handle most of these things. I can't find the issue which suggested it right now, but it was quite popular and if i remember correctly, there were steps being taken to implement it.I think it was ultimatly shafted for some newly discovered technical reason.
If anybody knows which issue i'm talking about and happens to find it, it could potentially add quite a bit to these discussions. Might also be worth evaluating wether it could be implemented now, because it was discussed in length it could save us a lot of effort as would have to discuss very similar thing eventually

@woshiguabi
Copy link
Author

@KilianKilmister

I have to be nitpickey here, but this is also new syntax. I also don't think this would address the issue in this thread. They do have overlap tho.

😜Thanks much! I mean no need for another new syntax.

https://github.com/tc39/proposal-record-tuple
Notice this stage 2 proposal, maybe we will finally achieve our goal through this

@Andarist
Copy link
Contributor

@RyanCavanaugh what's the process for adding this to the language by the external contributor? is this proposal non-destructive enough that a PR could be made and there would be a high chance of merging it after refining the details during the code review process?

@malcolmstill
Copy link

I'm trying to do something that I believe the above suggestion would allow.

Let's say I want to use https://github.com/vultix/ts-results, but I'd like to:

  • restrict all errors in my program to be a particular shape
  • get exhaustive checking of an error type with a kind or type field

If I wrapts-results Err function so that it takes <E extends string> (giving a new MyErr type say), I can do:

function double(a: number) {
    if (a < 0.0) {
        return Err({type: 'ERROR_LESS_THAN_ZERO' as const});
    } else if (a > 100.0) {
        return Err({type: 'ERROR_MORE_THAN_ONE_HUNDRED' as const});
    }    

    return Ok(a * 2.0);
}

...and I am able to get completion and exhaustive checking of those type tags. I.e. the return type inference gives:

function double(a: number): MyErr<{
    type: "ERROR_LESS_THAN_ZERO";
}> | MyErr<{
    type: "ERROR_MORE_THAN_ONE_HUNDRED";
}> | TSResults.Ok<number>

But the problem is if I forget to add an as const on any of my errors I get this:

function double(a: number): MyErr<{
    type: string;
}> | TSResults.Ok<number>

...and I've lost the discriminated union of type tags.

I'd love some way to say <E extends const string> (instead of <E extends string>) and so be forced to add as const on the type: field (or even better, not need to add the as const at all)

@tjjfvi
Copy link
Contributor

tjjfvi commented Aug 17, 2022

@malcolmstill You can use the Narrow utility type for that:

type Narrow<T> =
  | (T extends infer U ? U : never)
  | Extract<T, number | string | boolean | bigint | symbol | null | undefined | []>
  | ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> })

declare function Err<E extends { type: string }>(error: Narrow<E>): E;

const e = Err({ type: 'ERROR_LESS_THAN_ZERO' }) // no `as const` needed
//    ^? - { type: "ERROR_LESS_THAN_ZERO" }

@malcolmstill
Copy link

@tjjfvi thanks, as you say Narrow lets me get rid of the as const, but let me clarify why that isn't sufficient for what I'm trying to do. If I write double in a different way:

function double(a: number, someString: string) {
    if (a < 0.0) {
        return Err({type: 'ERROR_LESS_THAN_ZERO' as const});
    } else if (a > 100.0) {
        return Err({type: 'ERROR_MORE_THAN_ONE_HUNDRED' as const});
    }  else if (a > 50.0) {
        return Err({type: someString}); // I would like this to be a compile error because I want to enforce that type is const
    }

    return Ok(a * 2.0);
}

The above compiles, but collapses the return type to:

function double(a: number): MyErr<{
    type: string;
}> | TSResults.Ok<number>

...I want it instead to not compile, and force me to make type: const

@knownasilya
Copy link

knownasilya commented Sep 24, 2022

A use case I've run into needs this sort of functionality. https://www.typescriptlang.org/pt/play?#code/C4TwDgpgBACghgJwM4QKoIDbwXAtkgHgFdMA+KAXihIyggA9gIA7AEySgAMASAbwEtmAMwgIoYOMAAWAXwAUfQSLEB7MMH4rmcLJNkBKTgCgoUAPyxEKdLpz4CE6eQBklhBp0Fs1zNjyE1DS0deCdSEygALmpMOkYWdi5FYVEoJGBEYBkAemTlKAQIdJljUwtvNF9EfwJ0zJc3H1sawvTw02iaOKY2Dk5IvNSJOxKIi14oAG0AayhBcWrcAF1o9IRBAHMoGQjo3hkAbiMjAGMMOCQOACUVIiYEAgAVboTriBOVBFZa4HXmDYANGtNqRyLwIqZcHAwNFHkcIVAPsw1kQTsBPnIoTCoI99FBwaZCYTpPwkAA6LGUKBYo5EnYItQsAhQADSL16UGmEBAKiEONIckcUkiLIBCzsSGiFRsfnsj0mLKWpH00QAbip+Kx8QiiYVgCRmLTCTt6Uj0gVbvcqcwIAB3KA3O6iOQEwkAIxUKmmkqgAHJsh6vUhfQCEb7A96yRHfdF-RGkNlIhGAJKsX1GGRQC6IrTpfTHBCW0RkxnMOThz2R6Ni-b50654AWp0IABM1rtDqLCBdCPjsYDleDoaJFaDUcrMb9A6DiZTaYzdcLzZbJcgZdHVYnNZk+aAA

Basically it's hard to infer from strings when the input isn't a const.

Linked code

type ParseUrlParams = url extends `${infer path}(${infer optionalPath})`
  ? ParseUrlParams & Partial>
  : url extends `${infer start}/${infer rest}`
  ? ParseUrlParams & ParseUrlParams
  : url extends `:${infer param}`
  ? { [k in param]: string }
  : {};

class Router<T extends Record<string,string>> {
map: T;

constructor(map: T) {
    this.map = map;
}

open< K extends keyof T>(path:K, params: ParseUrlParams<T[K]>): void {
    return;
}

}

const router = new Router({
books: '/books',
'books.book': '/books/:bookId'
} as const)

router.open('books.book', {})

const router2 = new Router({
books: '/books',
'books.book': '/books/:bookId'
})

router2.open('books.book', {})

@Flutterish
Copy link

I have a use case for this where I provide a const literal as a function parameter which decodes data into the format which the literal specifies - for example decode( someDataView, { someData: 'u16', otherData: 'string' } as const ). It gives really weird errors when the format is not const.

@zen0wu
Copy link

zen0wu commented Dec 4, 2022

(Centralizing the conversation from #51745)

I want to define an API for users to specify a fixed path in an object, consider

type Obj = {
  a: {
    b: {
      c: "123"
    }
  }
}

type Get<T, K> = K extends keyof T ? NonNullable<T[K]> : never

type GetPath<T, P> = P extends PathKey[]
	? P extends []
		? T // If P is empty, return T.
		: P extends [infer K, ...infer Rest]
		? GetPath<Get<T, K>, Rest> // Else, recurse.
		: never
	: never

function set<T, P extends string[]>(obj: T, path: P, value: GetPath<T, P>) {}

set(
  obj, 
  ["a", "b", "c"],
  value
)

Currently, typescript would infer this set call's P as string[], hence value will be inferred as never.

This however, works when P extends string, so one way of doing this would be

function set<T, P extends string>(obj: T, path: [P], value: unknown) {}
function set<T, P1 extends string, P2 extends string>(obj: T, path: [P1, P2], value: unknown) {}
function set<T, P1 extends string, P2 extends string, P3 extends string>(obj: T, path: [P1, P2, P3], value: unknown) {}
// ...

This feels both cumbersome and not scalable.

With the proposed change, it'll look like

funciton set<T, P extends const string[]>(obj: T, path: P, value: GetPath<T, P> {}

On the other hand, sometimes people do want their generics to be inferred as string, not the literal passed in.

Allowing a const bound would allow developers to express precisely what they want.

@Andarist
Copy link
Contributor

Andarist commented Dec 6, 2022

@zen0wu This one actually can be done quite easily even today:
TS playground

The only drawback to this is that I have no idea how to make autocomplete work for this - without computing all possible paths eagerly.

@zen0wu
Copy link

zen0wu commented Dec 6, 2022

@Andarist good to know, thank you!

just so that i understand correctly, doing [...P] kind of enforces TS to infer it as a tuple type? is that a recommended best practice?

@Andarist
Copy link
Contributor

Andarist commented Dec 6, 2022

just so that i understand correctly, doing [...P] kind of enforces TS to infer it as a tuple type?

Yes

is that a recommended best practice?

Not sure. It's what I do though :P it seems to me like a way better approach than the older Tuple constraint workaround (type Tuple<T> = T[] | [T])

@zen0wu
Copy link

zen0wu commented Dec 7, 2022

just so that i understand correctly, doing [...P] kind of enforces TS to infer it as a tuple type?

Yes

is that a recommended best practice?

Not sure. It's what I do though :P it seems to me like a way better approach than the older Tuple constraint workaround (type Tuple<T> = T[] | [T])

@Andarist Actually I tried this out. It has this weird behavior where.. it works when the definition of the variable and the type is in the same file, but when they're in different files, this breaks (TS falls back to [string] on P), I don't understand why...

@Andarist
Copy link
Contributor

Andarist commented Dec 8, 2022

Not sure if I understand - this only impacts how T might be inferred. If your variable is in a different file then it doesn't participate in the inference on the call site.

If you'd like to ensure that you can't pass any values that are not literals etc - then this might get more tricky. You can try to use this remapping technique with the presented Validate:
TS playground

In more complex scenarios it might be tricky to actually validate your things this way but usually you can get good results

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

13 participants