Skip to content

Enforcing types to be narrowed from generic types #26332

Closed

Description

Search Terms

enforce narrowing, narrow types, narrow, force narrow type

Suggestion

First of all, sorry if there is already a way to do this, I've been reading thru the documentation, searched thru the issues, and crawled the web in order to find anything, but haven't so far.

I would like a way to force narrow types to be passed to my function, and specifically disallow the generic versions (e.g. string, number, boolean).

What I want is for my function to accept any subtype of e.g. string, but not string itself. So both 'a' | 'b', 'a', and 'hello' | 'world' | '!' would be accepted, but not string.

Use Cases

I think that this is one of the most illustrating use cases:

mafintosh/is-my-json-valid#165

Basically, I want to have a type that maps a JSON Schema into a TypeScript type. I then provide a function with a return value of input is TheGeneratedType.

Currently, the enum values need to be typed with 'test' as 'test' in order not to be generalized as just string:

// Current behavior:
function foobar<{ enum: [1, 2, 3] }>(input: any): input is number

// Wanted behavior:
function foobar<{ enum: [1, 2, 3] }>(input: any): input is 1 | 2 | 3

Examples

interface EnumSchema {
  enum: SubtypeOf<string>[] | SubtypeOf<number>[] | SubtypeOf<boolean>[]
}

function foobar(input: EnumSchema)

// Illigal
foobar({ enum: ['test' as string] })

// Allowed
foobar({ enum: ['test'] })

Related

#16896

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. new expression-level syntax)

Activity

  1. LinusU commented on Aug 9, 2018

    @LinusU
    ContributorAuthor

    I might be missing something, but I cannot get that to work with an array 🤔

    type MustBeStringUnion<T extends string> = string extends T ? never : T
    
    interface EnumSchema<T extends string> {
      enum: MustBeStringUnion<T>[]
    }
    
    declare function foobar<T extends string>(t: EnumSchema<T>): boolean
    
    foobar({ enum: ['a' as 'a', 'b' as 'b'] })

    But yeah, unfortunately, my main concern is for the consumers of is-my-json-valid not to have to type 'a' as 'a'...

    Maybe this is the wrong approach though 🤔

    The interesting thing is that I can sort of get it to work on the required field, but not on nested required fields. See here: https://github.com/mafintosh/is-my-json-valid/blob/8ef04cc80eff073e4ebbe5af17cbdd877aa4b443/test/typings.ts#L114-L135

    So possibly this could be solved instead with variadic kinds instead (#5453) 🤔

    Something like this:

    interface EnumSchema<...E> {
        enum: [...E]
    }
    
    declare function foobar<...E>(schema: EnumSchema<...E>): boolean
    
    foobar({ enum: [1, 2] })

    Which would basically select the variant: foobar<1, 2>(...)

    Andy (Andrewkraft) (@Andy-MS) if you feel like it, I would love some quick eyes on these definitions, it seems like I might be missing some smart trick to fix the nested require types 🤔 ❤️

    https://github.com/mafintosh/is-my-json-valid/blob/master/index.d.ts

  2. LinusU commented on Aug 9, 2018

    @LinusU
    ContributorAuthor

    The goal with the package is to be able to infer the TypeScript interface from the JSON Schema, such as to avoid having to type it twice.

    I wasn't able to get TypeScript to infer even the simplest schema when going the other way around:

    declare type SchemaFor<T> =
        T extends null ? { type: "null" }
        : T extends boolean ? { type: "boolean" }
        : T extends number ? { type: "number" }
        : T extends string ? { type: "string" }
        : never
    
    declare function createValidator<T>(schema: SchemaFor<T>): any
    
    // This errors with: Argument of type '{ type: string; }' is not assignable to parameter of type 'never'.
    createValidator({ type: 'string' })

    One huge thing when writing an API is dealing with input validation, and when using TypeScript I almost always end up typing everything twice; once in JSON Schema, and then in TypeScript as well in order to get proper typings.

    This is very tedious and also leads to hard to spot mistakes, e.g. I small mismatch between the JSON Schema and the TypeScript interface can make me think that everything is working, but when I deploy the API it will actually reject the input I intended.

    I really appreciate you taking the time to respond to me 💌

  3. LinusU commented on Aug 9, 2018

    @LinusU
    ContributorAuthor

    Wow, this is amazing 😍 Looks great 👏

    Now I only need to get the required behavior to work also, but I think this will work!!

    Thank you so much, this is awesome ❤️


    Actually, I've been here before 😂

    The problem now is that when we add the required behavior all the keys will be of type string instead of the literal.

    --- a.ts	2018-08-09 20:56:58.000000000 +0100
    +++ b.ts	2018-08-09 20:57:52.000000000 +0100
    @@ -5,8 +5,8 @@
     interface StringSchema { type: 'string' }
     interface ArraySchema<ItemSchema extends AnySchema> { type: 'array', item: ItemSchema }
     interface AnyArraySchema extends ArraySchema<AnySchema> {}
    -interface ObjectSchema<PropertiesSchemas extends Record<string, AnySchema>> { type: 'object', properties: PropertiesSchemas }
    -interface AnyObjectSchema extends ObjectSchema<Record<string, AnySchema>> {}
    +interface ObjectSchema<PropertiesSchemas extends Record<string, AnySchema>, RequiredFields extends keyof PropertiesSchemas> { type: 'object', properties: PropertiesSchemas, required?: RequiredFields[] }
    +interface AnyObjectSchema extends ObjectSchema<Record<string, AnySchema>, any> {}
     
     type TypeFromSchema<S> =
         S extends NullSchema ? null
    @@ -14,7 +14,7 @@
       : S extends NumberSchema ? number
       : S extends StringSchema ? string
       : S extends ArraySchema<infer ItemSchema> ? ArrayFromSchema<ItemSchema>
    -  : S extends ObjectSchema<infer PropertySchemas> ? { [K in keyof PropertySchemas]: TypeFromSchema<PropertySchemas[K]> }
    +  : S extends ObjectSchema<infer PropertySchemas, infer RequiredFields> ? { [K in keyof PropertySchemas]: (K extends RequiredFields ? TypeFromSchema<PropertySchemas[K]> : (TypeFromSchema<PropertySchemas[K]> | undefined)) }
       : never
     interface ArrayFromSchema<S> extends Array<TypeFromSchema<S>> {}

    The failure will then be:

    const input = null as unknown
    
    const personValidator = createValidator({
      type: 'object',
      properties: {
        name: { type: 'string' },
        age: { type: 'number' },
      },
      required: [
        'name'
      ]
    })
    
    if (personValidator(input)) {
      assertType<string>(input.name)
      assertType<number | undefined>(input.age)
    }

    Which can be fixed by:

     required: [
    -  'name'
    +  'name' as 'name
     ]
  4. LinusU commented on Aug 9, 2018

    @LinusU
    ContributorAuthor

    Oh, and we can fix this by adding an overload specifically for an object schema, where the object schema is the root:

    +declare function createValidator<PropertiesSchemas extends Record<string, AnySchema>, RequiredFields extends keyof PropertiesSchemas>(schema: ObjectSchema<PropertiesSchemas, RequiredFields>): TypeFromSchema<ObjectSchema<PropertiesSchemas, RequiredFields>> // Return TypeFromSchema<S> to make it easy to test
     declare function createValidator<S extends AnySchema>(schema: S): TypeFromSchema<S> // Return TypeFromSchema<S> to make it easy to test

    But that leads us to the next problem:

    const input = null as unknown
    
    const user2Validator = createValidator({
      type: 'object',
      properties: {
        name: {
          type: 'object',
          properties: {
            first: { type: 'string' },
            last: { type: 'string' },
          },
          required: [
    
            'last'   // <----- `as 'last'` is required here. ------
    
          ]
        },
        items: {
          type: 'array',
          items: { type: 'string' },
        }
      },
      required: [
        'name'
      ]
    })
    
    if (user2Validator(input)) {
      // --------
      // ...otherwise `input.name` will be `never` here, since string doesn't extend keyof {first: ..., last: ...}
      // --------
      assertType<{ first: string | undefined, last: string }>(input.name)
      assertType<string | undefined>(input.name.first)
      assertType<string>(input.name.last)
    
      if (input.items !== undefined) {
        assertType<number>(input.items.length)
        assertType<string>(input.items[0])
      }
    }

    and here I'm at a loss 🤔

  5. LinusU commented on Aug 10, 2018

    @LinusU
    ContributorAuthor

    Ahh, yeah that makes sense 🤔

    I'm having a bit of a hard time grasping exactly how existential types works, even after reading the other thread twice 😆

    But from what I gathered it would allow me to express this:

    That would also allow you to express relations between types, such as that required must be a subset of the property keys.

    Which is awesome!

    I'll guess I'll have to go with a ton of overloads for now, but it would be really really nice to see existential types, and variadic kinds get into TypeScript ☺️

    Still, I'm not sure that that would fix my initial problem with the enum type? I would still need a way to express that the enum type cannot be a generic type, and have to be a narrow type?

    interface EnumSchema<T> { enum: T[] }
    declare function createValidator<T>(schema: EnumSchema<T>): T[]
    
    // This would give `number[]` instead of `(1 | 2 | 3)[]`
    createValidator({ enum: [1, 2, 3] })
    
    // This would work though
    createValidator({ enum: [1 as 1, 2 as 2, 3 as 3] })

    I don't have a perfect proposal for how this could be solved though 🤔

    Maybe just a narrow keyword?

    interface EnumSchema<narrow T> { enum: T[] }
    declare function createValidator<narrow T>(schema: EnumSchema<T>): T[]

    That would disallow the generic types string, number, boolean, but I'm not too familiar to say if this would be a good solution 🤔

  6. AlCalzone commented on Aug 10, 2018

    @AlCalzone
    Contributor

    If you want to avoid writing code for a schema, maybe you could go with a VSCode extension?
    https://marketplace.visualstudio.com/items?itemName=quicktype.quicktype
    (or the underlying library: https://github.com/quicktype/quicktype)

    I haven't used it yet, but at first glance that looks kinda like what you want to do here.

  7. LinusU commented on Aug 10, 2018

    @LinusU
    ContributorAuthor

    Seems very cool!

    Although my express goal right now is to provide typings for the package is-my-json-valid which already have a ton of downloads. I want to provide out-of-the-box awesome experience when using VS Code and is-my-json-valid 🙌

  8. typescript-bot commented on Jul 13, 2019

    @typescript-bot
    Contributor

    This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow.

  9. simeyla commented on Jul 4, 2020

    @simeyla

    TLDR; You probably can make use of the new <const> which will narrow a string such as 'Apple' to the type Apple. It's still a string, but it's now distinct from a Banana.

    See this question and my answer: https://stackoverflow.com/questions/37978528/typescript-type-string-is-not-assignable-to-type/55387357

  10. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions