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

Add an absent type forbidding presence #55143

Open
4 tasks done
RobertSandiford opened this issue Jul 25, 2023 · 12 comments
Open
4 tasks done

Add an absent type forbidding presence #55143

RobertSandiford opened this issue Jul 25, 2023 · 12 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

@RobertSandiford
Copy link

Suggestion

πŸ” Search Terms

type, absent, unset, none, missing, undeclared, not set, undefined

βœ… Viability Checklist

  • 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, new syntax sugar for JS, etc.)
  • [I think so?] This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Add a type (working title absent) that forbids presence of the property or variable.

πŸ“ƒ Motivating Example

The absent type would allow forbidding presence of a property on an object, forbidding a property or method in a derived class, and would allow abstract properties that can be configured to be required or not through generics.

πŸ’» Use Cases

Consider a front end, react-like-framework, using classes.

The absent type combined with a generic would allow toggling between using state, and enforcing type safety on that state (requires initialisation), or not using state, which forbids presence of state. (This is my use case and motivation for this change)

abstract class FrameworkComponent<State extends (object | absent) = absent>{
  abstract state: State
}

class StatelessAppComponent extends FrameworkComponent {
  // specifying state here would violate `absent` type, showing a type error
  render => () => 'Hi - I am a stateless component'
}

type StatefulAppComponentState = {
  message: string
}

class StatefulAppComponent extends FrameworkComponent<StatefulAppComponentState> {
  // omitting state here would violate the requirement of state: StatefulAppComponentState property
  state: StatefulAppComponentState = {
    message: 'Hi - I am a stateful component'
  }
  render => () => this.state.title
}

Here is an example from Next today. The state property is not typesafe, and this code produces a run time error, despite no type errors.

'use client'

import React from 'react'

type Props = object
type State = {
    a: number,
}   

export default class Component extends React.Component<Props, State> {
    render() {
        this.state /// Readonly<State>
        console.log('state.a', this.state.a) // runtime error
        return <p>Component</p>
    }
}

To avoid this the framework would have to require state to be set in every component, with stateless components having to add state: undefined or state = undefined, or create a StatelessComponent variant. This is not the end of the world, it's a bit of an annoyance (seemingly enough of an annoyance that React has sacrificed type safety to avoid it).

export default class StatelessComponent extends React.Component {
    state: undefined
    render() {
        return <p>StatelessComponent </p>
    }
}

Consider a payment processing system, where we want to prevent double processing of orders

type OrderUnpaid = {
  price: number,
  paymentId: absent
}

type OrderPaid = {
  price: number,
  paymentId: string
}

function processPayment(order: OrderUnpaid): OrderPaid {
  return {
    ...order,
    paymentId: 'abcd1234'
  }
}

let order: OrderUnpaid = {
  price: 57
}

// assigning OrderPaid type to OrderUnpaid
order = processPayment(order) // type error, type 'OrderPaid' cannot be assigned to type 'OrderUnpaid', 'paymentId' must be absent in 'OrderUnpaid'

// accidental second payment processing
order = processPayment(order)

//or

const orderPaid = processPayment(order)

// accidental second payment processing
// passing OrderPaid type to function expecting OrderUnpaid
processPayment(orderPaid) // type error, type 'OrderPaid' does not satisfy the constraint 'OrderUnpaid', 'paymentId' must be absent in 'OrderUnpaid'

How this looks in TypeScript today

Playground

type OrderUnpaid = {
  price: number
}

type OrderPaid = {
  price: number,
  paymentId: string
}

function processPayment(order: OrderUnpaid): OrderPaid {
  return {
    ...order,
    paymentId: 'abcd1234'
  }
}

let order: OrderUnpaid = {
  price: 57
}

// assigning a wider object to an existing object is allowed
order = processPayment(order)

// accidental second payment processing
processPayment(order) // allowed, application bug

//or

const orderPaid = processPayment(order)

// accidental second payment processing
// passing a wider object to a function is allowed
processPayment(order) // allowed, application bug

An Order type with an option paymentId does not improve safety

type Order = {
  price: number
  paymentId?: string
}

undefined and ?: syntax

{ prop: undefined } requires prop to be present

{ prop?: 'foo' } allows prop to be absent, or set to undefined

This suggests some concept of absent already exists in TS.

Therefore, ?: T would equate to T | undefined | absent

absent variables

const foo: absent would probably not be allowed. const foo in javascript creates the variable, so this would be an oxymoron. This could be treated as a developers aid, being allowed, and emitting no javascript, but I don't see a use case, so its easier not to.

absent properties on the global/window object should I think prevent those variables being declared with var. I expect that this will very rarely be used, but it should really be enacted for consistency.

⇄ Relations to other issues

Exact types #12936 - this issue solves some of the same issues that Exact types could be used to solve. Especially in the second example. However in the first example it is serving a different role.

Exact types might rely on absent type, or might be user implementable using an absent type.

πŸ› οΈ Implementation challenges

I don't see anything super difficult. Unions and the like seem fine. Someone who knows more about TS internals can comment.

I'd be interested to hear how this type and concept would gel with existing code & infrastructure - whether it's something that would fit in naturally or not.

@RobertSandiford RobertSandiford changed the title Add an absent typing forbidding presence Add an absent type forbidding presence Jul 25, 2023
@Jamesernator
Copy link

Jamesernator commented Jul 25, 2023

{ prop?: 'foo' } allows prop to be absent, or set to undefined

There already exists exactOptionalPropertyTypes which does exactly the same thing, in that it doesn't allow prop?: 'foo' to include undefined.

The fact abstract doesn't work work with optional types as I mentioned honestly seems like a bug.

@MartinJohns
Copy link
Contributor

IMO this approach just sounds like a hack. The much cleaner approach would be to use the undefined type (or a unique symbol). Check for values of properties, not presence of properties.

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 25, 2023

There already exists exactOptionalPropertyTypes

Interesting. It seems like a good move, but I am guessing that it is off in strict mode due to back compat issues. Do you know?

If you are suggesting that that flag provides the suggested feature, I am sure it doesn't.

It reduces the acceptable values of ?: T from T | undefined | absent to T | absent. It doesn't allow allow absent alone, and therefore can't be used with generics to narrow S extends (object | absent) = absent down to specific type.

With optional abstract properties you could do

abstract class Component<S extends object> {
  abstract state?: S
}

but this wouldn't require the derived class to actually set state. The problem is the same with or without exactOptionalPropertyTypes. Your state property would always be S | undefined, and every time you work with state, framework side or user side, you have to check whether its undefined in order to be typesafe. It's not worth doing, better just to typecast state to S, and hope that the user initialized it, so no real benefit there.

In order to narrow down from extends (object | absent) to an object or absent, there has to be an actual named concept of absent.

@MartinJohns
Copy link
Contributor

Interesting. It seems like a good move, but I am guessing that it is off in strict mode due to back compat issues. Do you know?

It's not even enabled in strict mode by default, due to the large amount of issues it can cause.

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 25, 2023

IMO this approach just sounds like a hack. The much cleaner approach would be to use the undefined type (or a unique symbol). Check for values of properties, not presence of properties.

I don't see what this has to do with the suggestion. undefined and absent are different.

In the framework example, using undefined requires each component to implement state even if not using it, which is what I am trying to avoid.

In the second example you could do runtime checks on the values of properties, but you have no checking of your code and are relying on your own logic. With this suggestion typescript verifies the safety of your code, as opposed to needing to write, maintain and run automated tests to do the same checks.

The absence of a variable or property is an important part of the meta-data on an object. If we can only talk of what an object has, and not of what an object does not have, we are missing part of the picture in describing that object. Imagine trying to describe a person with a missing left leg, while only being able to say what he has and not what he does not have. You can only create a description of a person who has his other limbs, and may or may not have his left leg. This is our current description of objects, unless I am missing something very important.

@RyanCavanaugh
Copy link
Member

There's a reason that some information goes on the property slot (optionality) and some goes on the type, and that there aren't any type modifiers which change information about the property slot.

Once you have absent, then a bunch of stuff stops making sense:

  • What is absent | string, and how does that differentiate from just x?: string under EOPT?
  • What is absent & string ? Is it really just never ?
  • What happens if you have a parameter of type absent, especially in a non-trailing position?
  • What does let x: absent mean?
  • "No no no you just wouldn't write those things" - ok but you will have them appear in generic instantiation; for any T<U>, T<absent> now needs to have some specific meaning, meaning that anywhere a type can appear, you have to have some sensible answer about what the higher-order compositional effects of seeing an absent are.

I can understand why some scenarios seem to call for per-site exactOptionalPropertyTypes but you can read discussion on that flag to understand why it's generally better suited as a global setting.

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 25, 2023

@RyanCavanaugh

What is absent | string, and how does that differentiate from just x?: string under EOPT?

It's the same.

What is absent & string ? Is it really just never ?

Absolutely

What happens if you have a parameter of type absent, especially in a non-trailing position?

In a trailing position its the same as not being there (TS blocks additional args), unless that is configurable.
In other positions it would create invalid typings, or forced any subsequent optional not to be passed, should probably throw a compiler error or warning

What does let x: absent mean?

This was covered, probably it is prohibited, as oxymoronic

"No no no you just wouldn't write those things" - ok but you will have them appear in generic instantiation; for any T, T now needs to have some specific meaning, meaning that anywhere a type can appear, you have to have some sensible answer about what the higher-order compositional effects of seeing an absent are.

OK good point. I'll have a think about it

I can understand why some scenarios seem to call for per-site exactOptionalPropertyTypes but you can read discussion on that flag to understand why it's generally better suited as a global setting.

I don't see the connection to this suggestion. The suggestion is about prohibiting props, not making them optional. Especially for reducing a Generic down to one or the other and not leaving it optional.

@RyanCavanaugh
Copy link
Member

I don't see the connection to this suggestion

Because under EOPT, your request is satisfied by

type OrderUnpaid = {
  price: number,
  paymentId?: never
}

type OrderPaid = {
  price: number,
  paymentId: string
}

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 25, 2023

@RyanCavanaugh

I see. But that doesn't cover the first use case (which is my use case). Because I can't switch between abstract state: S and abstract state?: never using generics. Hence bringing absent into the type space.

So perhaps the issue is how to bring the ?: concept into a Generic or conditional

Something like

abstract class C<(S extends (object | ?never))> {
    abstract state: S // extends-object or ?: never 
}

But maybe we are opening up new problems again.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Jul 27, 2023
@dead-claudia
Copy link

How does this differ from never?

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 28, 2023

How does this differ from never?

A property type never means that the property exists, but it will not return anything, even undefined, if accessed - for example it has a getter that will throw an error or exit the program.

absent simply means that the property does not exist.

See #55121

@RobertSandiford
Copy link
Author

RobertSandiford commented Jul 29, 2023

Further thoughts

(1) Concept: When working with logic, working with the absense of things can be just as important as the presence. TS currently has wide support for discussion things that are present, such as passing a type to a generic that will then be used or required. However TS does not have equal support for optionals or absense. An optional property can be declared with ?:, but this expression is not supported within generics, and cannot be passed around, subjected to conditionals or specified in uses of generic devices. Excluding a property requires the exactOptionalPropertyTyes flag, which is false by default, and may not be
viable for established projects. It's also a fairly technical workaround for the lack of an absent type, even if its simply enough to use.

I found some small issues with how typescript unions types, and with how JS turns absence of values into undefined (such as assigning from a function that does not return). But I think they can be worked around and are not deal breakers. Of course if anyone and suggest cleaner solutions, that would be great!

Functions

(2) A function returning absent is generally equivalent to a function returning void, Where a void returning function is required, actually a function with any return type can be used (the returned value is simply not used). A requirement for a function returning absent could involved a strict requirement for a return type of absent or void.

(() => {})() === undefined, so this leads to the rule that if absent is assigned to a variable, the variable becomes type undefined. This clashes somewhere with how void is treated.

Alternatively absent as a return type could simply be forbidden without major issue.

This discussion does raise the question of whether void should simply be expanded to cover the use cases of absent, but this doesn't work, as we can create a property of type void and assign it the result of a void returning function. This is not at all what absent is for.

(3) function f(a?: boolean) is equivalent to function f(a: boolean | undefined | absent). Allowing absent would allow slightly more control in the form of function f(a: boolean | absent), similar to exactOptionalPropertyTypes.
I don't see a real world use for this though.

The use of absent is most useful in generics, e.g.

function f<T extends unknown | absent = absent>(): (a: T) => void {
    return (a: T) => {}
}
const f1 = f() // (a: absent) => {} == () => {}
const f2 = f<boolean>() // (a: boolean) => {}

(4) Including absent in the default set of types available in generics (the unknown union I believe?) breaks things

function f<T>(a: T): T {
    return a // this fails because an 'absent' 'a' leads to a === undefined
}

Either we can avoid this by saying that returning undefined satisfies the constraint of absent, or we can make absent opt in.

(4A) The undefined satisfies absent type - this won't work for object properties, so I'm disregarding this.

(4B) The opt-in route
<T extends (unknown | absent)> or a shorthand like <T?> (I'm not totally sold on this short form, because it can be misread as "T is optional" not "T can be the type absent"), or (arbitrarily) <#T>

completing the generic function:

function f<#T>(a: T): T {
    // a is type 'unknown | absent'
    // if we assigned a to const b, b would be type 'unknown | undefined', collapsing to unknown in this instance
    if (arguments.length >= 1) {
	// a is inferred to be type 'unknown'
        return a
    } else {
        // a is inferred to be type 'absent'
        // don't return
    }
}
// note: if we modify 'arguments', then information may be lost, and TS
// will need to understand this during type inferences base on arguments
// e.g.
// arguments.length = 0
// delete arguments[0]

(5) We can create uncallable functions

const f<#U, #T> {
  return (u: U, t: T) => undefined
}
const uf = f<absent, boolean>() // (u: absent, t: boolean) => undefined

But I don't think this is a problem. "Just don't do it".

Object Types

(6) We can require that a property does not exist type T = { p: absent }

{ p?: T } is the same as { a: T | absent } with exactOptionalPropertyTypes.
Without exactOptionalPropertyTypes. { a?: T } is { p: T | undefined | absent }
This allows projects that are already committed to exactOptionalPropertyTypes: false to specify a type that they could not.

?: absent is the same as : absent with exactOptionalPropertyTypes.
Without exactOptionalPropertyTypes { p?: absent } is the same as { p: undefined | absent } is the same as { p?: undefined }. Pretty weird, but that is the result of inexact optional property types.

(7) The whatever type (rename if you like). whatever is defined as unknown | absent.

Regardless of exactOptionalPropertyTypes, { p: whatever } is equivalent to { p: unknown | absent } is equivalent to { p?: unknown } is equivalent to { }.

whatever says that we don't care about the property, just as if we didn't declare it at all. Because 'whatever' is a type identity, unlike ?: unknown, we can use it in generics. whatever can be used to remove a property from the final type produced by a generic. This could also be used by higher order types who's goal is to remove properties (although it seems that this is already achieved an alternative way, that is rewriting to the key to type never, in Omit: https://dev.to/ajaykumbhare/2-implement-custom-omit-type-in-typescript-2i0g).

(8) Type building with absent and whatever

type T<#TT = whatever> = {
  a: string
  b: TT
}

type A = T // { a: string }
type B = T<boolean> // { a: string, b: boolean }
type C = T<absent> // { a: string, b: absent }

One small issue with the above is that we can't allow the user to use T<unknown | absent> but forbid T<absent>, because absent is a subset of unknown | absent. However this looks to be a wider issue, consider the following:

type A = string | boolean
type B = boolean
type T<TT extends A | B> = {
  a: TT
}
type U = T<string> // Allowed. We want this to error, because it is neither precisely A nor B

Perhaps this could be solved with a new || operator requiring precisely A or B. But that is a separate issue (if anyone knows a solve for that please let me know).

(9) As pointed out, { p?: never } is functionally the same as { p: absent } with exactOptionalPropertyTypes. However this is not because something of type never can't be assigned to p, but rather because if it is attempted, it will never complete, meaning that property p exists in in theory but not in practice, negating the possibility of the never variant, forcing absence instead. This is a fairly convoluted and "hacky" way to achieve : absent. I also think this is likely to contribute to misunderstandings about the never type, as people may not understand the workings beneath this and think of never as absent. A literal absent is a cleaner and simpler approach`

In the case of exactOptionalPropertyTypes: false, we are not providing functionality that we missing, we seems like a good thing to do. exactOptionalPropertyTypes is false by default, surely there are many projects already committed to that mode.

Uses

(10) uses

(10A) Type builders

type whatever = unknown | absent
type MAKE<#A = whatever, #B = whatever, #C  = whatever, #D = whatever> = {
    a: A
    b: B
    c: C
    d: D
    e: string
    f: number
}

type T = MAKE<string, number> // { a: string, b: number, e: string, f: number }
type U = MAKE<whatever, whatever, string, number> // { c: string, d: number, e: string, f: number }

Currently we can do this with intersections, but with the type builder above, a library provider can provide a controlled type builder that is less error prone

type Base = {
    e: string
    f: number
}

type T = Base & {
    a: string
    b: number
}

 // what if we want to constrain the types that 'a' can use? We can do
type MAKE<A extends string | boolean | whatever = whatever, B extends unknown | whatever = whatever> = {
    a: A
    b: B
    e: string
    f: number
}

type U = MAKE<string, number> // error, 'string' not compatible with type 'string | boolean | whatever'

(10B) Function builders. See point 2 above.

(10C) Abstract classes

As mentioned earlier in the ticket. Useful for React-like framework that want to have typesafe state

abstract class Component<State extends object | absent = absent> {
    abstract state: State
    run() {
        if ('state' in this) {
            // do something with this.state
        }
    }
}

class Button extends Component {} // may not declare state property
class Button extends Component<{ text: string }> {} // must declare state property of given type

We can use undefined instead of absent, but then a class extending P has to declare a field a: undefined
or a = undefined (see the Next.js example in the OP)

N.B. seems to be a bug in TS without useDefineForClassFields / target: >= ES2022, where class C { a: undefined } produces an object with no properties, but TS allows this to be assign to a type { a: undefined }

(10D) Other uses? I feel like some creative people will find other ways to use this. It's a fundamental tool, that might be used in many other ways that don't immediately occur.

Edit: Here is CustomOmit from Implement custom Omit Type in TypeScript implemented with absent (via whatever)

interface Todo {
    title: string;
    description: string;
    completed: boolean;
}

type CustomOmitWithNeverKeys<T,K extends keyof T> = {
   [Key in keyof T as Key extends K ? never : Key] : T[Key]
}

type CustomOmitWithAbsent<T,K extends keyof T> = {
  [Key in keyof T] : Key extends K ? whatever : T[Key]
}

type TodoCustomOmitWithNeverKeys = CustomOmitWithNeverKeys<Todo, "title">;
type TodoCustomOmitWithAbsent = CustomOmitWithAbsent<Todo, "title">;

I think that's a cleaner and simpler way to implement Omit, and that's a good sign to me that this suggestion is going in the right direction.

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

No branches or pull requests

5 participants