microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.93k stars 12.47k forks source link

Symbol type is incorrectly generalized when used as a property value of an object literal #36876

Open pmeller opened 4 years ago

pmeller commented 4 years ago

TypeScript Version: 3.7.5

Search Terms: symbol infer type

Expected behavior:

Symbol const uniqueSymbol = Symbol() when used as property value in an object literal is inferred as unique type typeof uniqueSymbol.

Actual behavior:

Symbol const uniqueSymbol = Symbol() when used as property value in an object literal is inferred as general type symbol.

Related Issues:

Code

const uniqueSymbol = Symbol()

// Example of incorrect inference (unique symbol is generalized to type `symbol`)

const foo = {
  prop: uniqueSymbol,
}

type Foo = typeof foo  // { prop: symbol }

// Workarounds for incorrect behavior

const bar = {
  prop: uniqueSymbol as typeof uniqueSymbol,
}

type Bar = typeof bar // { prop: typeof uniqueSymbol }

const genericFactory = <T>(value: T) => ({ prop: value })

const baz = genericFactory(uniqueSymbol)

type Baz = typeof baz // { prop: typeof uniqueSymbol }
Output ```ts "use strict"; const uniqueSymbol = Symbol(); // Example of incorrect inference (unique symbol is generalized to type `symbol`) const foo = { prop: uniqueSymbol, }; // Workarounds for incorrect behavior const bar = { prop: uniqueSymbol, }; const genericFactory = (value) => ({ prop: value }); const baz = genericFactory(uniqueSymbol); ```
Compiler Options ```json { "compilerOptions": { "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noImplicitThis": true, "noImplicitReturns": true, "useDefineForClassFields": false, "alwaysStrict": true, "allowUnreachableCode": false, "allowUnusedLabels": false, "downlevelIteration": false, "noEmitHelpers": false, "noLib": false, "noStrictGenericChecks": false, "noUnusedLocals": false, "noUnusedParameters": false, "esModuleInterop": true, "preserveConstEnums": false, "removeComments": false, "skipLibCheck": false, "checkJs": false, "allowJs": false, "declaration": true, "experimentalDecorators": false, "emitDecoratorMetadata": false, "target": "ES2017", "module": "ESNext" } } ```

Playground Link: Provided

RyanCavanaugh commented 4 years ago

@rbuckton I believe this is the intended behavior, but can you provide context?

weswigham commented 4 years ago

Same is true of other literal types:

const uniqueSymbol = "tag"

const foo = {
  prop: uniqueSymbol,
}

type Foo = typeof foo 

prop is a mutable object member. Assigning a singleton type to a mutable member is usually not useful, so we widen at mutable positions. Do note, there is no way to declare an immutable object property (only class fields).

pmeller commented 4 years ago

Taking into consideration all primitive types this seems to be consistent. I just intuitively expected something else as a programmer since symbols are unique values.

My specific (simiplified) use case to give you more context (React application):

// Application state business logic
const loading = Symbol()
type Resource = { id: number; name: string }

// `loading` is used to encode loading state, `undefined` for not found
const getResource: () => Observable<typeof loading | undefined | Resource> = ...

const useApplicationState = () => {
  const resource: typeof loading | undefined | Resource = useObservable(() => getResource(), [])

  return {
    resource, // type of symbol is widened here
  }
}

// UI component
const { resource } = useApplicationState()

if (resource === loading) {
  // render spinner
} else if (resource)
  // render resource details
  // this won't work as `typeof resource` is `Resource | symbol`, expected was just `Resource`
} else {
  // render not found
}

I ended up with type casting as the simpliest solution:

const useApplicationState = () => {
  const resource: typeof loading | undefined | Resource = useObservable(() => getResource(), [])

  return {
    resource: resource as typeof resource,
  }
}
pmeller commented 4 years ago

@weswigham Please note that there is quite simple way to enforce string literal type for string primitives, whereas it's not possible for symbols (because their type isn't known before initialization):

type UniqueString = 'UNIQUE_STRING'
const uniqueString: UniqueString = 'UNIQUE_STRING'

const foo = {
  prop: uniqueString,
}

type Foo = typeof foo // { prop: 'UNIQUE_STRING' }

const uniqueSymbol: unique symbol = Symbol()
type UniqueSymbol = typeof uniqueSymbol

const bar = {
  prop: uniqueSymbol,
}

type Bar = typeof bar // { prop: symbol }

Playground link

pmeller commented 4 years ago

It seems that main issue that I've described works consistently for primitive types but at the same time TS as a language lacks other mechanisms for type symbol that allow to avoid generalization of variable that is a specific symbol. Please take a look at following techniques:

const literalString0 = 'literalString1'
const literalNumber0 = 1
const literalBoolean0 = true

// literal types are widened
const obj0 = {
  literalString0,
  literalNumber0,
  literalBoolean0,
}

// literal types are preserved

// technique #1 using `as const`
const literalString1 = 'literalString1' as const
const literalNumber1 = 1 as const
const literalBoolean1 = true as const

const obj1 = {
  literalString1,
  literalNumber1,
  literalBoolean1,
}

// technique #2 using explicit type declaration
const literalString2: 'literalString1' = 'literalString1'
const literalNumber2: 1 = 1
const literalBoolean2: true = true

const obj2 = {
  literalString2,
  literalNumber2,
  literalBoolean2,
}

Playground link

Both as const casting and explicit type declaration cannot be used on symbols.