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

Exact Types #12936

Open
blakeembrey opened this issue Dec 15, 2016 · 272 comments
Open

Exact Types #12936

blakeembrey opened this issue Dec 15, 2016 · 272 comments
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

@blakeembrey
Copy link
Contributor

blakeembrey commented Dec 15, 2016

This is a proposal to enable a syntax for exact types. A similar feature can be seen in Flow (https://flowtype.org/docs/objects.html#exact-object-types), but I would like to propose it as a feature used for type literals and not interfaces. The specific syntax I'd propose using is the pipe (which almost mirrors the Flow implementation, but it should surround the type statement), as it's familiar as the mathematical absolute syntax.

interface User {
  username: string
  email: string
}

const user1: User = { username: 'x', email: 'y', foo: 'z' } //=> Currently errors when `foo` is unknown.
const user2: Exact<User> = { username: 'x', email: 'y', foo: 'z' } //=> Still errors with `foo` unknown.

// Primary use-case is when you're creating a new type from expressions and you'd like the
// language to support you in ensuring no new properties are accidentally being added.
// Especially useful when the assigned together types may come from other parts of the application 
// and the result may be stored somewhere where extra fields are not useful.

const user3: User = Object.assign({ username: 'x' }, { email: 'y', foo: 'z' }) //=> Does not currently error.
const user4: Exact<User> = Object.assign({ username: 'x' }, { email: 'y', foo: 'z' }) //=> Will error as `foo` is unknown.

This syntax change would be a new feature and affect new definition files being written if used as a parameter or exposed type. This syntax could be combined with other more complex types.

type Foo = Exact<X> | Exact<Y>

type Bar = Exact<{ username: string }>

function insertIntoDb (user: Exact<User>) {}

Apologies in advance if this is a duplicate, I could not seem to find the right keywords to find any duplicates of this feature.

Edit: This post was updated to use the preferred syntax proposal mentioned at #12936 (comment), which encompasses using a simpler syntax with a generic type to enable usage in expressions.

@HerringtonDarkholme
Copy link
Contributor

I would suggest the syntax is arguable here. Since TypeScript now allows leading pipe for union type.

class B {}

type A = | number | 
B

Compiles now and is equivalent to type A = number | B, thanks to automatic semicolon insertion.

I think this might not I expect if exact type is introduced.

@normalser
Copy link

Not sure if realted but FYI #7481

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Dec 15, 2016

If the {| ... |} syntax was adopted, we could build on mapped types so that you could write

type Exact<T> = {|
    [P in keyof T]: P[T]
|}

and then you could write Exact<User>.

@DanielRosenwasser DanielRosenwasser added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Dec 15, 2016
@joshaber
Copy link
Member

This is probably the last thing I miss from Flow, compared to TypeScript.

The Object.assign example is especially good. I understand why TypeScript behaves the way it does today, but most of the time I'd rather have the exact type.

@blakeembrey
Copy link
Contributor Author

@HerringtonDarkholme Thanks. My initial issue has mentioned that, but I omitted it in the end as someone would have a better syntax anyway, turns out they do 😄

@DanielRosenwasser That looks a lot more reasonable, thanks!

@wallverb I don't think so, though I'd also like to see that feature exist 😄

@rotemdan
Copy link

rotemdan commented Dec 17, 2016

What if I want to express a union of types, where some of them are exact, and some of them are not? The suggested syntax would make it error-prone and difficult to read, even If extra attention is given for spacing:

|Type1| | |Type2| | Type3 | |Type4| | Type5 | |Type6|

Can you quickly tell which members of the union are not exact?

And without the careful spacing?

|Type1|||Type2||Type3||Type4||Type5||Type6|

(answer: Type3, Type5)

@blakeembrey
Copy link
Contributor Author

blakeembrey commented Dec 17, 2016

@rotemdan See the above answer, there's the generic type Extact instead which is a more solid proposal than mine. I think this is the preferred approach.

@rotemdan
Copy link

rotemdan commented Dec 17, 2016

There's also the concern of how it would look in editor hints, preview popups and compiler messages. Type aliases currently just "flatten" to raw type expressions. The alias is not preserved so the incomperhensible expressions would still appear in the editor, unless some special measures are applied to counteract that.

I find it hard to believe this syntax was accepted into a programming language like Flow, which does have unions with the same syntax as Typescript. To me it doesn't seem wise to introduce a flawed syntax that is fundamentally in conflict with existing syntax and then try very hard to "cover" it.

One interesting (amusing?) alternative is to use a modifier like only. I had a draft for a proposal for this several months ago, I think, but I never submitted it:

function test(a: only string, b: only User) {};

That was the best syntax I could find back then.

Edit: just might also work?

function test(a: just string, b: just User) {};

(Edit: now that I recall that syntax was originally for a modifier for nominal types, but I guess it doesn't really matter.. The two concepts are close enough so these keywords might also work here)

@rotemdan
Copy link

rotemdan commented Dec 19, 2016

I was wondering, maybe both keywords could be introduced to describe two slightly different types of matching:

  • just T (meaning: "exactly T") for exact structural matching, as described here.
  • only T (meaning: "uniquely T") for nominal matching.

Nominal matching could be seen as an even "stricter" version of exact structural matching. It would mean that not only the type has to be structurally identical, the value itself must be associated with the exact same type identifier as specified. This may or may not support type aliases, in addition to interfaces and classes.

I personally don't believe the subtle difference would create that much confusion, though I feel it is up to the Typescript team to decide if the concept of a nominal modifier like only seems appropriate to them. I'm only suggesting this as an option.

(Edit: just a note about only when used with classes: there's an ambiguity here on whether it would allow for nominal subclasses when a base class is referenced - that needs to be discussed separately, I guess. To a lesser degree - the same could be considered for interfaces - though I don't currently feel it would be that useful)

@ethanresnick
Copy link
Contributor

This seems sort of like subtraction types in disguise. These issues might be relevant: #4183 #7993

@blakeembrey
Copy link
Contributor Author

@ethanresnick Why do you believe that?

@johnnyreilly
Copy link

This would be exceedingly useful in the codebase I'm working on right now. If this was already part of the language then I wouldn't have spent today tracking down an error.

(Perhaps other errors but not this particular error 😉)

@mohsen1
Copy link
Contributor

mohsen1 commented Feb 17, 2017

I don't like the pipe syntax inspired by Flow. Something like exact keyword behind interfaces would be easier to read.

exact interface Foo {}

@blakeembrey
Copy link
Contributor Author

@mohsen1 I'm sure most people would use the Exact generic type in expression positions, so it shouldn't matter too much. However, I'd be concerned with a proposal like that as you might be prematurely overloading the left of the interface keyword which has previously been reserved for only exports (being consistent with JavaScript values - e.g. export const foo = {}). It also indicates that maybe that keyword is available for types too (e.g. exact type Foo = {} and now it'll be export exact interface Foo {}).

@mohsen1
Copy link
Contributor

mohsen1 commented Feb 19, 2017

With {| |} syntax how would extends work? will interface Bar extends Foo {| |} be exact if Foo is not exact?

I think exact keyword makes it easy to tell if an interface is exact. It can (should?) work for type too.

interface Foo {}
type Bar = exact Foo

@basarat
Copy link
Contributor

basarat commented Feb 19, 2017

Exceedingly helpful for things that work over databases or network calls to databases or SDKs like AWS SDK which take objects with all optional properties as additional data gets silently ignored and can lead to hard to very hard to find bugs 🌹

@blakeembrey
Copy link
Contributor Author

blakeembrey commented Feb 19, 2017

@mohsen1 That question seems irrelevant to the syntax, since the same question still exists using the keyword approach. Personally, I don't have a preferred answer and would have to play with existing expectations to answer it - but my initial reaction is that it shouldn't matter whether Foo is exact or not.

The usage of an exact keyword seems ambiguous - you're saying it can be used like exact interface Foo {} or type Foo = exact {}? What does exact Foo | Bar mean? Using the generic approach and working with existing patterns means there's no re-invention or learning required. It's just interface Foo {||} (this is the only new thing here), then type Foo = Exact<{}> and Exact<Foo> | Bar.

@RyanCavanaugh
Copy link
Member

We talked about this for quite a while. I'll try to summarize the discussion.

Excess Property Checking

Exact types are just a way to detect extra properties. The demand for exact types dropped off a lot when we initially implemented excess property checking (EPC). EPC was probably the biggest breaking change we've taken but it has paid off; almost immediately we got bugs when EPC didn't detect an excess property.

For the most part where people want exact types, we'd prefer to fix that by making EPC smarter. A key area here is when the target type is a union type - we want to just take this as a bug fix (EPC should work here but it's just not implemented yet).

All-optional types

Related to EPC is the problem of all-optional types (which I call "weak" types). Most likely, all weak types would want to be exact. We should just implement weak type detection (#7485 / #3842); the only blocker here is intersection types which require some extra complexity in implementation.

Whose type is exact?

The first major problem we see with exact types is that it's really unclear which types should be marked exact.

At one end of the spectrum, you have functions which will literally throw an exception (or otherwise do bad things) if given an object with an own-key outside of some fixed domain. These are few and far between (I can't name an example from memory). In the middle, there are functions which silently ignore
unknown properties (almost all of them). And at the other end you have functions which generically operate over all properties (e.g. Object.keys).

Clearly the "will throw if given extra data" functions should be marked as accepting exact types. But what about the middle? People will likely disagree. Point2D / Point3D is a good example - you might reasonably say that a magnitude function should have the type (p: exact Point2D) => number to prevent passing a Point3D. But why can't I pass my { x: 3, y: 14, units: 'meters' } object to that function? This is where EPC comes in - you want to detect that "extra" units property in locations where it's definitely discarded, but not actually block calls that involve aliasing.

Violations of Assumptions / Instantiation Problems

We have some basic tenets that exact types would invalidate. For example, it's assumed that a type T & U is always assignable to T, but this fails if T is an exact type. This is problematic because you might have some generic function that uses this T & U -> T principle, but invoke the function with T instantiated with an exact type. So there's no way we could make this sound (it's really not OK to error on instantiation) - not necessarily a blocker, but it's confusing to have a generic function be more permissive than a manually-instantiated version of itself!

It's also assumed that T is always assignable to T | U, but it's not obvious how to apply this rule if U is an exact type. Is { s: "hello", n: 3 } assignable to { s: string } | Exact<{ n: number }>? "Yes" seems like the wrong answer because whoever looks for n and finds it won't be happy to see s, but "No" also seems wrong because we've violated the basic T -> T | U rule.

Miscellany

What is the meaning of function f<T extends Exact<{ n: number }>(p: T) ? 😕

Often exact types are desired where what you really want is an "auto-disjointed" union. In other words, you might have an API that can accept { type: "name", firstName: "bob", lastName: "bobson" } or { type: "age", years: 32 } but don't want to accept { type: "age", years: 32, firstName: 'bob" } because something unpredictable will happen. The "right" type is arguably { type: "name", firstName: string, lastName: string, age: undefined } | { type: "age", years: number, firstName: undefined, lastName: undefined } but good golly that is annoying to type out. We could potentially think about sugar for creating types like this.

Summary: Use Cases Needed

Our hopeful diagnosis is that this is, outside of the relatively few truly-closed APIs, an XY Problem solution. Wherever possible we should use EPC to detect "bad" properties. So if you have a problem and you think exact types are the right solution, please describe the original problem here so we can compose a catalog of patterns and see if there are other solutions which would be less invasive/confusing.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed In Discussion Not yet reached consensus labels Mar 7, 2017
@christianalfoni
Copy link

christianalfoni commented Jun 9, 2024

I visit this issue quite often to see its development. The use case I have the most for Exact type is related to return types in library code. So for example:

// We are testing a param VS a return value
function someLibraryFunction<T>(param: T, cb: () => T) {}

// Just some type
type SomeType = {
    foo: string
}

someLibraryFunction<SomeType>(
    // "bar" will give an error here
    { foo: 'bar', bar: 'baz'},
    () => ({
        foo: 'bar',
        // But it does not give an error here
        bar: 'baz'
    }
))

But if I do this:

someLibraryFunction<SomeType>(
    // "bar" gives an error
    { foo: 'bar', bar: 'baz'},
    // Setting the return type explicitly
    (): SomeType => ({
        foo: 'bar',
        // Now "bar" gives an error
        bar: 'baz'
    }
))

I think this is quite confusing and as a library author you are just exposing the someLibraryFunction function in this case, so you can not enforce the exact return type of the callback here. You either have to document to the user that they need to remember to add this explicit type also as return type, or you need to expose a special utility function that does nothing but validate typing through its param, only to return it. It makes me cry every time 😢

@Peeja
Copy link
Contributor

Peeja commented Jun 9, 2024

To break it down further (but separate it from its use case context):

((): SomeType => ({
    foo: "bar",
    // "bar" will give an error here
    bar: 'baz'
}));

(() => ({
    foo: "bar",
    // But it does not give an error here
    bar: 'baz'
})) satisfies () => SomeType;

@dead-claudia
Copy link

dead-claudia commented Jun 10, 2024

@christianalfoni That sounds less like this, and more like a bug in TS's existing lint for excess object literal properties. It should be able to catch that, and it normally can even in concrete return value positions.

Exact types could catch that, but there's value in fixing that independent of exact types.

Edit: this does not address @Peeja's comment, just @christianalfoni's.

@christianalfoni
Copy link

christianalfoni commented Jun 10, 2024

@dead-claudia Oh, that is interesting. I have found this weird behaviour on the playground where it actually does detect the excess property, but then when I remove the property and add it again, or refresh, it suddenly does not report it as an error anymore.

You think it is worth putting this into a new issue? I thought it maybe had something to do with generics. So like a type passed as a generic and used as a return type will be treated as "structurally similar", as opposed to "exact" 🤔

@dead-claudia
Copy link

@dead-claudia Oh, that is interesting. I have found this weird behaviour on the playground where it actually does detect the excess property, but then when I remove the property and add it again, or refresh, it suddenly does not report it as an error anymore.

You think it is worth putting this into a new issue? I thought it maybe had something to do with generics. So like a type passed as a generic and used as a return type will be treated as "structurally similar", as opposed to "exact" 🤔

@christianalfoni Yes, it'd be worth filing a new issue for. Consider that you're already seeing one of the two erroring, but not the other, and consider the fact it works for return values for functions whose return type isn't a generic type parameter.

It concerns generics, but that's an issue for that new issue (or its related PR) to tease out.

@christianalfoni
Copy link

Okay, nice, I added an issue here #58826

@dead-claudia Would you mind taking a quick look in case I wrote something ambiguous? Thanks for helping me address this issue 😄

@RobertSandiford
Copy link

RobertSandiford commented Jun 30, 2024

I had a little play at implementing this (first attempt to modify tsc), and it was pretty straight forwards to implement a basic exact object implementation - that was without getting into advanced type logic and so on.

The big thing I noticed when looking at this though, was that exact types and standard types would be non-interchangeable. Obviously a standard types can't be assigned to an exact types, because it could have extra keys. But an exact types also can't be assigned to ordinary types , because ordinary types can be used in a way that allows them to grow, i.e. have extra properties added. So at first glance it seems that if we had exact types, we'd create a kind of standards war, where it would be annoying to use libraries and packages designed for the other type than the kind that you want to use (reminds me a little of CJS vs ESM).

There is a solution of sorts, which is a 3rd type, which I'm calling a noextend type (or "non-extendable" if you like), which is essentially a normal type that can have extra properties, but it can't be assigned to a normal type slot, only other noextend types, but not a noextend type that has an extra optional property (which is how normal objects gain properties, at least the way I know). The special quality of this noextend type is that it can be assigned to from both normal types and exact types, so therefore acts as a universal type that would work well for libraries and frameworks, because it is tolerant of the kind of type that the user prefers, supporting assignment from all 3 kinds of type.

Converting from a normal or noextend type to an exact type would require making a new value with the specific properties. Converting from an exact type or a noextend type to a normal type would require simply cloning the value, so that extension doesn't affect existing references. Both could be automated with helper functions.

I think that the noextend type would also solve one of the silly irregularities with normal types:

const o1 = { a: 'aa', b: 'bb' }
const o2: { a: string } = o1
const o3: { a: string, b?: number } = o2
if ('b' in o3) {
    o3.b // TS thinks b is a number
}

This kind of property addition by assignment is unsound, so a noextend type also gives a type similar to what we are used to, without that unsoundness.

const o1 = { a: 'aa', b: 'bb' }
const o2: noextend { a: string, b?: string } = o1 // OK because b exists in o1
const o3: noextend { a: string } = o1 // OK, extra props allowed
const o4: noextend { a: string, b?: number } = o3 // Unsafe, forbidden

So I think that this noextend type has independent merit, and seems also like it would be simple to implement (much more so than exact types).

Personally, I'm leaning towards noextend + exact being the best route to go down if we want to implement this.

Inviting your thoughts and opinions.

@nmn
Copy link

nmn commented Jun 30, 2024

@RobertSandiford

But an exact object also can't be assigned to ordinary object, because ordinary objects can be used in a way that allows them to grow, i.e. have extra properties added.

A simpler solution would be to allow assigning an exact object to a Readonly object since it cannot be mutated.

But also, I think this unsoundness is consistent with other variance unsoundness in TS and should be accepted.

e.g.

const cats: Array<Cats>
const animals: Array<Animal> = cats

This is technically incorrect as well, because arrays are mutable and now it's possible to insert non-cat animals into the animals array, which, in turn, will insert non-cat animals into the cats array.

I think the exact object vs regular object type issue is similar to this.

@RobertSandiford
Copy link

A simpler solution would be to allow assigning an exact object to a Readonly object since it cannot be mutated.

We don't have readonly objects, only readonly properties. Making the properties readonly doesn't affect adding extra properties.

const o1 = { a: 'aa', b: 'bb' }
const o2: Readonly<{ a: string }> = o1
const o3: Readonly<{ a: string, b?: number }> = o2 // allowed
if ('b' in o3) {
    o3.b // TS still thinks b is a number
}

But also, I think this unsoundness is consistent with other variance unsoundness in TS and should be accepted.

I don't think this is related to variance unsoundness, beyond being another type of unsoundness. { a: string, b?: string} and { a: string, b?: number } are not related as a supertype and subtype. Saying that TS is unsound in another location, so we should accept unsoundness here is not a good perspective from my point of view. Unsoundness in TS typically exists I think because it would be a hassle to deal with the issues arising from it, such as making common/existing patterns unwieldly to type. I don't see a big issue with an opt in type like this, that would be largely transparent to the end user if used in a library. Of course compiler complexity and performance still needs to be considered.

Exact objects definitely have a real complexity cost, and a certain implementation challenge as mentioned. Another issues is that an exact object does not extend generally extend other exact objects, so writing generic type constraints is an issue. I think this could be solved by a "magic" exactobject type that all exact objects would extend, to be used in constraints. I guess a value types as exactobject would be essentially unusable, so it would only be useful as a constraint in generic or satisfies.

@MartinJohns
Copy link
Contributor

We don't have readonly objects, only readonly properties.

We don't even have that, because the readonly type is implicitly compatible with the mutable type. The property is only readonly via that specific interface, not in general.

@RobertSandiford
Copy link

We don't even have that, because the readonly type is implicitly compatible with the mutable type. The property is only readonly via that specific interface, not in general.

I didn't know that. The more I learn, the less safe I feel.

const o: Readonly<{ a: 'foo' }> = { a: 'foo' }
const p: {  a: string } = o
p.a = 'bar'
o.a // TS thinks it is 'foo'

Although I suppose that in general mutable properties are unsafe, because of the old { a: string | number } = { a: string } allowance. readonly 'foo' is assignable to string so it really seems to be the same unsafe covariance permit. But this is really off topic for this thread.

@Exifers
Copy link

Exifers commented Sep 8, 2024

This would help to perform mutations on generics.

Currently if T[K] is a generic type, we cannot constrain it so that something like number is assignable to it.
This makes some simple functions impossible to implement with generics:

function reset<T extends Record<K, number>, K extends keyof T>(v: T, k: K) {
  v[k] = 0 // Error: requires number to be assignable to T[K], but T[K] could be arbitrarily precise
}

function increment<T extends Record<K, number>, K extends keyof T>(v: T, k: K) {
  v[k] = v[k] + 1 // Error: requires both T[K] to be assignable to number (ok) and number to be assignable to T[K]
}

Of course we could write these functions without generics for v, but this is not possible in the context of an ORM for example:

interface BaseEntity {
    id: string
}

function createOrm<Entity extends BaseEntity>() {
    return {
        reset<T extends Entity & Record<K, number>, K extends keyof T>(entity: T, key: K) {
            this.update(entity, key, 0) // Error not fixable for now
        },
        increment<T extends Entity & Record<K, number>, K extends keyof T>(entity: T, key: K) {
            this.update(entity, key, entity[key] + 1) // Error not fixable for now
        },
        update<T extends Entity, K extends keyof T>(entity: T, key: K, value: T[K]) {
            entity[key] = value

            // saves the entity here through DB request so uses entity.id
        }
    }
}

In this case we need the generics to ensure the user code will pass an Entity with an id to the update function.

Playground

If we'd have a way to specify that T[K] is exactly of type number, then it would be available for both reading and writing in the functions and they could be implemented without error.

@RobertSandiford
Copy link

RobertSandiford commented Sep 8, 2024

Currently if T[K] is a generic type, we cannot constrain it so that something like number is assignable to it. This makes some simple functions impossible to implement with generics:

This isn't the topic of the ticket, which is prohibiting additional properties on the value that are not present on the type.

Your issue is related to lack of invariant constraints on writable properties #18770 and somewhat also the lack of lower bound constraints #14520

(edit: edited the linked issues)

@dead-claudia

This comment has been minimized.

@jcalz
Copy link
Contributor

jcalz commented Sep 8, 2024

Those annotations can't go there.

@robbiespeed
Copy link

@Exifers while exact types may make it easier to implement the restrictions you want in that example, I don't think it's strictly necessary. You can instead restrict the K parameter, though you need to make some (safe) assertions inside the implementation.

function createOrm<Entity extends BaseEntity>() {
    return {
        reset<T extends Entity, K extends { [P in keyof T]: 0 extends T[P] ? P : never }[keyof T]>(entity: T, key: K) {
            this.update(entity, key, 0 as T[K]);
        },
        increment<T extends Entity, K extends { [P in keyof T]: number extends T[P] ? P : never }[keyof T]>(entity: T, key: K) {
            this.update(entity, key, ((entity[key] as number) + 1) as T[K]);
        },
        update<T extends Entity, K extends keyof T>(entity: T, key: K, value: T[K]) {
            entity[key] = value
        }
    }
}

Playground

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.