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

Enforcing types to be narrowed from generic types #26332

Closed
4 tasks done
LinusU opened this issue Aug 9, 2018 · 14 comments
Closed
4 tasks done

Enforcing types to be narrowed from generic types #26332

LinusU opened this issue Aug 9, 2018 · 14 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@LinusU
Copy link
Contributor

LinusU commented Aug 9, 2018

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)
@ghost
Copy link

ghost commented Aug 9, 2018

You can sort of do this with mapped types:

type MustBeStringUnion<T extends string> = string extends T ? never : T;

declare function f<T extends string>(input: MustBeStringUnion<T>): void;

f('some random string'); // Error

type U = "a" | "b";
declare const u: U;
f(u); // Error?
f<U>(u); // OK

The conditional type seems to break inference though, so you'd need an explicit type annotation.

@LinusU
Copy link
Contributor Author

LinusU commented Aug 9, 2018

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-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

@ghost
Copy link

ghost commented Aug 9, 2018

Yeah, when the conditional type is involved you don't seem to get any type inference, and have to provide a type argument with foobar<"a" | "b">(...).

@ghost
Copy link

ghost commented Aug 9, 2018

Looking at the type definitions, it seems it would be a lot simpler if you inferred the schema from the type rather than the other way around:

export = createValidator
declare function createValidator<T>(schema: createValidator.SchemaFor<T>, options?: any): createValidator.Validator<T>

declare namespace createValidator {
    type Validator<T> = ((input: unknown) => input is T) & { errors: ValidationError[] }

    type SchemaFor<T> =
        T extends null ? { required?: boolean, type: "null" }
        : T extends boolean ? { required?: boolean, type: "boolean" }
        : T extends number ? { required?: boolean, type: "number" }
        : T extends string ? { required?: boolean, type: "string" }
        : T extends Array<infer U>
        ? { type: "array", items: SchemaFor<U> }
        : ObjectSchemaFor<T>
    interface ObjectSchemaFor<T> {
        type: "object"
        properties: { [K in keyof T]: SchemaFor<T[K]> }
        required?: boolean
        additionalProperties?: boolean
    }

    interface ValidationError {
        field: string
        message: string
        value: unknown
        type: string
    }

    function filter<T>(schema: SchemaFor<T>, options?: any): Filter<T>
    type Filter<T> = (t: T) => T
}

Then the example from the README will compile:

import validator = require('is-my-json-valid')

interface I {
  hello: string;
}

var validate: validator.Validator<I> = validator({
  required: true,
  type: 'object',
  properties: {
    hello: {
      required: true,
      type: 'string'
    }
  }
});

var filter: validator.Filter<I> = validator.filter({
  required: true,
  type: 'object',
  properties: {
    hello: {type: 'string', required: true}
  },
  additionalProperties: false
})

var doc = {hello: 'world', notInSchema: true}
console.log(filter(doc)) // {hello: 'world'}

@LinusU
Copy link
Contributor Author

LinusU commented Aug 9, 2018

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 💌

@ghost
Copy link

ghost commented Aug 9, 2018

It looks like you can get contextual types on string literals (and not have to write 'string' as 'string') by defining an AnySchema type -- and then use those a second time for inferring the interface.

type AnySchema = NullSchema | BooleanSchema | NumberSchema | StringSchema | AnyArraySchema | AnyObjectSchema
interface NullSchema { type: 'null' }
interface BooleanSchema { type: 'boolean' }
interface NumberSchema { type: 'number' }
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>> {}

type TypeFromSchema<S extends AnySchema> =
    S extends NullSchema ? null
  : S extends BooleanSchema ? boolean
  : 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]> }
  : never
interface ArrayFromSchema<S extends AnySchema> extends Array<TypeFromSchema<S>> {}

declare function createValidator<S extends AnySchema>(schema: S): TypeFromSchema<S> // Return TypeFromSchema<S> to make it easy to test

const sArr = createValidator({ type: 'array', item: { type: 'string' } })
const s: string = sArr[0]

const o = createValidator({ type: 'object', properties: { a: { type: 'boolean' } } })
const b: boolean = o.a

@LinusU
Copy link
Contributor Author

LinusU commented Aug 9, 2018

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
 ]

@LinusU
Copy link
Contributor Author

LinusU commented Aug 9, 2018

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 🤔

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Aug 9, 2018
@ghost
Copy link

ghost commented Aug 9, 2018

The problem with required is that the contextual type for it is erased by any in interface AnyObjectSchema extends ObjectSchema<Record<string, AnySchema>, any> {}.
I think to fix the required problem you would need the ability to specify that Required has some type K without specifying the value of K. Basically existential types (#14466).

declare function f0<K extends string>(i: I<K>): K;
const a0 = f0({ k: "a" }); // "a"

declare function f1<K extends string, T extends I<K>>(i: T): F<T>;
const a1 = f1({ k: "a" }); // "a"

type F<T> = T extends I<infer K> ? K : never;
declare function f2<T extends I<string>>(i: T): F<T>;
const a2 = f2({ k: "a" }); // string

// Existential type:
declare function f3<T extends I<?>>(i: T): F<T>;
const a3 = f2({ k: "a" }); // "a"

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

@LinusU
Copy link
Contributor Author

LinusU commented Aug 10, 2018

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 🤔

@AlCalzone
Copy link
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.

@LinusU
Copy link
Contributor Author

LinusU commented Aug 10, 2018

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 🙌

@typescript-bot
Copy link
Collaborator

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.

@simeyla
Copy link

simeyla commented Jul 4, 2020

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants