How should union of Objects with optional properties be defined with Flow? - javascript

I have a function that deals with two type of parameters: string and object. There are 3 different object structure expected. That makes up to 4 possibles types:
type URL = string;
type Item = {| href: string |};
type ItemList = {| results: Item[] |};
type Params = {| offset?: number, limit?: number |};
So the function's options type is:
type Options = URL | Item | ItemList | Params;
And here's the actual function:
// No properties of "Params" are required
// so if they're all omitted, Params === {}
function request(opts: Options = {}) {
if (typeof opts === 'string') {
return 'opts is an URL';
}
if (typeof opts.href === 'string') {
return 'opts is an item';
}
if (Array.isArray(opts.results)) {
return 'opts is a list of items';
}
// Three of the four types are caught
// we're left with "Params" which may or may not
// have a "offset" and "limit" property.
// Destructuring to undefined values is fine here.
// Still, flow complains about the type not being met.
const { offset, limit } = opts;
return 'opts are parameters';
}
Flow is complaining about a few things:
opts = {} throws an incompatibility error. Since no properties of Params are required, shouldn't empty objects match it too? Note that opts = { offset: undefined } clears the error.
Properties "offset" and "limit" are declared not found. Since none of them are required, shouldn't undefined be valid values? And thus destructuring fine?
To summarize my question:
How do you define a type that accepts different types of object, with one having no required properties?
Edit: run the flow code in your browser.

Check out Flowtype - making a sealed empty object for an answer to your first question.
For your second, the answer basically is that Flow does not fully support this sort of type refinement. Disjoint unions were designed for this use case, though you do have to add a discriminator property to all of your objects. Obviously, this will require some nontrivial changes to your code. It's up to you to decide if this is feasible.
If it's not feasible, the best thing to do is probably to just cast through any in this function, and make sure you provide a type annotation for the return value. It looks like it's small enough that it's easy for a human to reason about, so the benefit of typechecking here may not be worth the effort. Of course that's a judgement call that is best left to you.

Related

How to get type key name from object key in typescript?

type transaction = {
uid: string,
paymentMode : string,
}
I want to get key name from type for e.g.
function getFieldName(input) {
returns a string with key name
}
const tran : transaction = {}
getKeyName(tran.paymentMode) returns 'paymentMode'
I read many articles and tried some solutions, so I understand we will at least need to create object for that type which is fine.
I want to do this because key name of a type is required and I also want to keep auto-complete for key name when we type something like obj.key to avoid mistakes.
I know, I can create a separate object from type with constant key, value pair. I want to avoid this, because then we will need to change same thing at 2 places whenever there is a change in type, which can be missed.
Edit:
I was looking for exactly this - nameof in c#. #Aleksey L. told this in comments.
This seems to be the type of function signature that you want:
Note that it meets your criteria of compatibility with IntelliSense auto-complete/suggest:
TS Playground
type Transaction = {
uid: string;
paymentMode: string;
};
function getFieldName <T extends object, K extends keyof T>(o: T, key: K): K {
return key;
}
const transaction: Transaction = {
uid: window.crypto.randomUUID(),
paymentMode: 'unknown',
};
getFieldName(transaction, 'uid'); // "uid"
getFieldName(transaction, 'paymentMode'); // "paymentMode"
getFieldName(transaction, 'asdf'); /*
~~~~~~
Argument of type '"asdf"' is not assignable to parameter of type 'keyof Transaction'.(2345) */
It is not possible to derive a string property name in JavaScript (or TypeScript) by passing a reference to the property value. JavaScript simply doesn't have this kind of meta-introspection capability at present.
It appears that your question is not just about type safety, but about DX/ergonomics. The above solution does require providing the property name, but the number of extra characters to type before getting the IntelliSense prompt is only 1 (2 if you consider whitespace):
// For: obj.prop:
// desired:
fn(obj.p_)
// 012^
// proposed:
fn(obj, 'p_')
// 01234^
// The closing quote is auto-inserted by IntelliSense, just like the closing parenthesis.

Dealing with type changing side effects in TypeScript

This is more of an open Question on how to deal with functions that have type altering side effects in TypeScript. I know and strongly agree with the notion, that functions should have as few side effects as possible, if any.
But sometimes, it is desirable to change an object (and it's type) in-place instead of creating a new copy of it with another static type. Reasons I have come across most frequently are readability, efficiency or reducing line count.
Since my original example was too convoluted and over-complicated a (hopefully) very basic example here:
type KeyList = 'list' | 'of' | 'some' | 'keys';
// Original type (e.g. loaded from a JSON file)
interface Mappable {
source: { [K in KeyList]: SomeNestedObject },
sourceOrder: KeyList[];
}
// Mapped Type (mapped for easier access)
interface Mapped {
source: { [K in KeyList]: SomeNestedObject },
sourceOrder: SomeDeepObject[];
}
// What I have to do to keep suggestions and strict types all the way
const json: Mappable = JSON.parse(data); // ignoring validation for now
const mapped: Mapped = toMappedData(json);
// What I would like to to
const mapped: Mappable = JSON.parse(data);
mapData(mapped); // mapped is now of type Mapped
Reasons, why I would like to mutate both object properties and it's type in-place could be:
The json object is very large and it would be counterproductive to have 2 copies of it in memory
It is very cumbersome to create a deep copy of the json object into the mapped object
I don't believe the code under "What I would like to do" is very readable, regardless of it not working. What I'm looking for is a clean and type safe way to navigate this issue. Or, alternativley, suggestions or ideas to extend typescript's functionality towards solving this issue.
Any suggestions, ideas and comments on this are greatly appreciated! Maybe I'm just in too deep with this and can't see the really simple solution.
I don't think what you want to do is possible. You asking typescript change a variables type as a side effect. But that introduces all kinds of complications.
What if the mapData function is run conditionally?
const mapped: Mappable = JSON.parse(data);
if (Math.random() > 0.5) {
mapData(mapped); // mapped is now of type Mapped
}
// What type is `mapped` here?
Or what if you pass a reference to this object before transforming it?
function doAsyncStuff(obj: Mappable) {}
doAsyncStuff(mapped)
mapData(mapped)
// Then later, doAsyncStuff(obj) runs but `obj` is a different type than expected
I think the closest you can get here is a typeguard with a cast to an intermediate type that supports the union of the pre-transform type and the post-transform type where you can actually do the transformation.
interface A {
foo: string
}
interface B {
foo: string
bar: string
}
interface A2B {
foo: string
bar?: string
}
function transform(obj: A): obj is B {
const transitionObj: A2B = obj
transitionObj.bar = "abc" // Mutate obj in place from type A to type B
return true
}
const obj: A = { foo: 'foo' }
if (transform(obj)) {
obj // type is B in this scope
}
obj // but obj is still type A here, which could lead to bugs.
But if you actually use obj as type A outside that conditional, then that could lead to runtime bugs since the type is wrong. So a typeguard function with side effects is also a really bad idea, since a typeguard lets you override typescripts normal typing.
I really think you're already doing the best approach here, especially if the output type is different from the input type. Immutably construct the new object as the new type.
const mapped: Mapped = toMappedData(json);
If performance or memory is a huge concern, then you may have to sacrifice type safety for that cause. Write robust unit tests, cast it to any, add very prominent comments about what's going on there. But unless you dealing with hundreds of MB of data at a time, I'm betting that's really not necessary.
The way I have done at the moment is:
type KeyList = 'list' | 'of' | 'some' | 'keys';
// Merged the types to keep the code a little shorter
// Also makes clear, that I don't alter the type's structure
interface MyType<M ext boolean = true> {
source: { [K in KeyList]: SomeNestedObject },
sourceOrder: (M ? SomeNestedObject : (KeyList | SomeNestedObject))[];
}
function mapData(data: MyType<true>): MyType {
const { source, sourceOrder } = data.sourceOrder
for (let i = 0; i < sourceOrder.length; i++) {
if (typeof sourceOrder[i] == 'string') {
sourceOrder[i] = source[sourceOrder[i]];
}
}
return (data as unknown) as MyType;
}
const json: MyType<true> = JSON.parse(data);
const mapped: MyType = mapData(json);
// mapped now references json instead of being a clone
What I don't like about this approach:
It's not type safe. Typescript can't check if I correctly mutate the type
Because of this I have to convert to unknown, which is not ideal
json type is not as strict. Could negatively impact code suggestions
The function has a side effect as well as a return type (exclusively side effect or return type would be cleaner)

Return Object (with specific key, value types) from function in TypeScript

Suppose I have a function that needs to return something of type StringMap<string, boolean>. An example return that is valid is: {"required": true}.
Now, I've read in a tutorial (it's not important which tutorial) you can create a function that has return type of { [s: string]: boolean } and this is the same return type as the StringMap above.
I don't understand how are these two the same return type? And how the second version is even valid?
All the return types I have seen in TypeScript have only included the type in the past i.e. boolean, number, any. For example function (): number {}. In our second version we use s: string which means we give the variable a name, and specify it's type, how are we suddenly allowed to give the variable the name s?
On top of that we put this string inside an array [s: string] as the key in the second version (therefore the key is now an array). While a StringMap has a string as the key.
The syntax is a bit different than you think. It's a unique syntax for defining dictionaries\maps.
{ [s: string]: boolean } means: a map, which has a key with type string, and it's values are boolean. The s means nothing at all, it could have been anything you want.
(Then why give it a name in the first place? my guess is to make the code more clear, when mapping more complex types. Sometimes you'll want to call the index id, sometimes address, etc..)
More info here, indexed types is what you want.
The Typescript handbook online isn't the most friendly documentation ever, but I think it's good enough and I recommend everyone who uses typescript to at least skim through it. Especially since in 2.0+ they added a bunch of crazy\awesome type features like mapped types.
The type { [s: string]: boolean } defines an indexable type interface.
What you see as an array is just the syntax decided to define the index of the interface.
The name of the key, as far as I know, is ignored and only the type is what matters.
This code { [s: string]: boolean } is defining an indexable interface where the indices are strings and the values are booleans.
I assume that the definition of StringMap is as follows:
export interface StringMap<T, U> = { [s: T]: U };
Which is kind of redundant if you ask me (as the name says that it should be a string map, so the keys should be strings). I would have declared the IStringMap interface as:
export interface IStringMap<T> = { [key: string]: T };
Interfaces in TypeScript just define the "shape" of the object. The previous three definitions have equivalent shapes, so this is perfectly valid:
function fn() : IStringMap<boolean> {
let myMap : StringMap<string, bool> = { };
myMap["foo"] = true;
myMap["bar"] = false;
myMap["baz"] = true;
return myMap;
}
let foo: { [bazzinga: string]: boolean } = fn();

Annotating functions in Flow that operate on union types containing optional parameters

My apologies for the bad title, I don't know how to better describe this :)
I'm using Flow in an application based on an IndexedDB database with auto-incrementing IDs. So basically, I create some objects (with no id property), write them to the database (at which point they are given an id property by IndexedDB), and then read them back out (any object read from the DB is guaranteed to have a numeric id property).
I have some functions that operate on these objects. Sometimes they only operate on objects with IDs, sometimes they only operate on objects without IDs, and sometimes they operate on both. It's the latter case that is trickiest. Here's one attempt, using two different types for the objects before and after being written to the DB (so, without and with the id property, respectively):
/* #flow */
type BeforeDb = {prop: string};
type AfterDb = BeforeDb & {id: number};
var beforeDb: BeforeDb = {prop: 'hi'};
var afterDb: AfterDb = {id: 1, prop: 'hi'};
function a(obj: BeforeDb | AfterDb): BeforeDb | AfterDb {
if (typeof obj.id === 'number') {
console.log(obj.id * 2);
}
return obj;
}
function b(obj: AfterDb) {}
var x = a(afterDb);
b(x);
(demo link)
That produces an error on the last line, because it doesn't know that x is of type AfterDb, and I'm not sure how to convey that information appropriately.
Another idea would be to use bounded polymorphisms, except I don't believe this can create something like my a function above because it can't handle the fact that id is sometimes undefined. Like I want to do something like this:
function a<T: {id?: number}>(obj: T): T {
if (typeof obj.id === 'number') {
console.log(obj.id * 2);
}
return obj;
}
(demo link)
but that doesn't work. If I assigned a dummy value to id so it was always numeric (like -1 instead of undefined) then this would work, but then I'd have to be very careful about remembering to delete the id before the first write to the DB so the real id could be auto-generated, which would be pretty ugly.
After that, I'm pretty much out of good ideas. The one thing I got to work was to use just one type, like:
type Obj = {id?: number, prop: string};
and then explicitly check if the id property is there or not in every function that uses the id property. But that's annoying, because I have a bunch of functions that are only called with the output of IndexedDB, so I already know id is guaranteed to be there. I just don't know how to tell Flow that.
Any ideas?
function a<T: BeforeDb | AfterDb>(obj: T): T {
if (typeof obj.id === 'number') {
console.log(obj.id * 2);
}
return obj;
}

Flowtype: generic Id<T> type with similar constraints to the type argument passed in

I have a generic Id<T: HasId> type which is structurally always just a string regardless of the type argument passed in as T. I'd like Id<T> types with different types passed as T to behave like different types.
For example, I would like the snippet const i :Id<Car> = p.id in the following code to cause a Flow error:
declare interface HasId {
id: string,
};
type Id<T: HasId> = string;
type Person = {
id: Id<Person>,
name: string,
};
type Car = {
id: Id<Car>,
make: string,
model: string,
};
const p :Person = { id: '1234', name: 'me' }
const c :Car = p; // Causes a Flow error, good!
const c :Id<Car> = p.id; // I want this to cause a Flow error,
// but currently it doesn't.
Furthermore, it would be nice if this could continue to work nicely with union types:
type Vehicle =
| Car
| Motorcycle
;
const t :Car = { id: '5678', make: 'Toyota', model: 'Prius' };
const v :Id<Vehicle> = c.id; // Currently does not cause Flow
// error; I want to keep it that way.
It sounds like what you want are opaque types, which Flow does not have yet. If you have a type alias type MyString = string, you can use string and MyString interchangeably. However, if you have an opaque type alias opaquetype MyNumber = number, you cannot use number and MyNumber interchangeably.
There is a longer explanation of opaque types on this GitHub issue.
The pretty-good-but-sorta-hacky solution
I did some experimentation and found a way to do what I specified in the question based on the system shown in this GitHub issue comment and the one following it. You can use a class (with a generic type parameter T) which Flow treats an opaque type, and use casting to any in order to convert between string and ID.
Here are some utilities that enable this:
// #flow
import { v4 } from 'node-uuid';
// Performs a "type-cast" from string to Id<T> as far as Flow is concerned,
// but this is a no-op function
export function stringToId<T>(s :string):Id<T> {
return (s :any);
}
// Use this when you want to treat the ID as a string without a Flow error
export function idToString(i :Id<*>):string {
return (i :any);
}
export function createId<T>():Id<T> {
return stringToId('1234');
}
// Even though all IDs are strings, this type distinguishes between IDs that
// can point to different objects.
export class Id<T> {};
With these utilities, the following code (similar to the original code in my question) will result in a Flow error, like I wanted.
// #flow
const p :Id<Person> = createId<Person>();
// note: Even though p is Id<Person> in Flow, its actual runtime type is string.
const c :Id<Car> = p; // this causes Flow errors. Yay!
// Also works without an explicit annotation for `p`:
const pp = createId<Person>();
const cc :Id<Car> = pp; // also causes Flow errors. Yay!
Downsides
The Flow output is unfortunately quite verbose, since type errors like this trigger multiple Flow errors. Even though the output isn't ideal, at least it behaves correctly in that making an error causes Flow to report an error.
Another issue is that with this solution, you have to explicitly convert from ID to string in cases such as object/map keys where Flow is not expecting an Id<*>, like this example:
// #flow
type People = { [key :string]: Person };
const people :People = {};
const p :Id<Person> = createId<Person>();
people[p] = createPerson(); // causes Flow error
// Unfortunately you always have to do this:
people[idToString(p)] = createPerson(); // no Flow error
These type conversion functions are just no-ops at runtime since all the Flow types are stripped out, so there may be a performance penalty if you call them a lot. See the GitHub issue I linked in this answer for some more discussion.
Note: I'm using Flow v0.30.0.

Categories